Merge branch 'MDL-63531-master' of git://github.com/andrewnicols/moodle
authorJun Pataleta <jun@moodle.com>
Tue, 30 Oct 2018 02:50:52 +0000 (10:50 +0800)
committerJun Pataleta <jun@moodle.com>
Tue, 30 Oct 2018 02:50:52 +0000 (10:50 +0800)
85 files changed:
admin/settings/analytics.php
admin/settings/courses.php
admin/tool/analytics/classes/output/form/edit_model.php
admin/tool/analytics/model.php
admin/tool/behat/lang/en/tool_behat.php
admin/tool/behat/tests/behat/list_steps.feature
admin/tool/dataprivacy/lang/en/tool_dataprivacy.php
admin/tool/dataprivacy/tests/behat/datadelete.feature
admin/tool/dataprivacy/tests/behat/dataexport.feature
admin/tool/lp/lang/en/tool_lp.php
admin/tool/messageinbound/classes/privacy/provider.php
admin/tool/messageinbound/tests/privacy_test.php
analytics/classes/manager.php
analytics/classes/model.php
analytics/tests/prediction_test.php
auth/classes/external.php
auth/classes/output/login.php
auth/db/lang/en/auth_db.php
auth/mnet/lang/en/auth_mnet.php
auth/tests/behat/login.feature
auth/tests/behat/verifyageofconsent.feature
auth/tests/external_test.php
availability/condition/profile/lang/en/availability_profile.php
blocks/myoverview/lang/en/block_myoverview.php
blocks/myoverview/templates/view-summary.mustache
blocks/tag_youtube/lang/en/block_tag_youtube.php
blocks/timeline/lang/en/block_timeline.php
cache/stores/mongodb/lang/en/cachestore_mongodb.php
course/externallib.php
course/tests/externallib_test.php
course/upgrade.txt
enrol/imsenterprise/lang/en/enrol_imsenterprise.php
grade/report/upgrade.txt
grade/report/user/externallib.php
grade/report/user/lib.php
grade/report/user/tests/externallib_test.php
lang/en/admin.php
lang/en/analytics.php
lang/en/debug.php
lang/en/group.php
lang/en/install.php
lang/en/message.php
lang/en/moodle.php
lang/en/question.php
lang/en/repository.php
lang/en/user.php
lib/classes/plugininfo/mlbackend.php
lib/db/install.xml
lib/db/services.php
lib/db/upgrade.php
lib/outputrenderers.php
lib/templates/course_header_image.mustache [new file with mode: 0644]
mod/assign/feedback/offline/lang/en/assignfeedback_offline.php
mod/assign/lang/en/assign.php
mod/choice/classes/privacy/provider.php
mod/choice/tests/privacy_provider_test.php
mod/forum/lang/en/forum.php
mod/lti/lang/en/lti.php
mod/quiz/lang/en/quiz.php
mod/quiz/report/grading/renderer.php [new file with mode: 0755]
mod/quiz/report/grading/report.php
mod/quiz/report/statistics/tests/behat/basic.feature
mod/quiz/report/statistics/tests/statistics_table_test.php
mod/quiz/tests/reportlib_test.php
question/type/calculated/lang/en/qtype_calculated.php
question/type/multichoice/lang/en/qtype_multichoice.php
question/type/shortanswer/lang/en/qtype_shortanswer.php
question/type/truefalse/lang/en/qtype_truefalse.php
report/performance/lang/en/report_performance.php
report/stats/classes/privacy/provider.php
report/stats/tests/privacy_test.php
theme/boost/config.php
theme/boost/scss/moodle.scss
theme/boost/scss/moodle/blocks.scss
theme/boost/scss/moodle/course.scss
theme/boost/scss/moodle/dashboard.scss [new file with mode: 0644]
theme/boost/style/moodle.css
theme/boost/templates/columns2.mustache
theme/boost/templates/core/block.mustache
theme/boost/templates/header.mustache
theme/bootstrapbase/config.php
theme/bootstrapbase/less/moodle/blocks.less
theme/bootstrapbase/less/moodle/course.less
theme/bootstrapbase/style/moodle.css
version.php

index b42a252..d58124c 100644 (file)
@@ -37,8 +37,8 @@ if ($hassiteconfig) {
             $predictors[$fullclassname] = new lang_string('pluginname', $pluginname);
         }
         $settings->add(new \core_analytics\admin_setting_predictor('analytics/predictionsprocessor',
-            new lang_string('predictionsprocessor', 'analytics'), new lang_string('predictionsprocessor_help', 'analytics'),
-            '\mlbackend_php\processor', $predictors)
+            new lang_string('defaultpredictionsprocessor', 'analytics'), new lang_string('predictionsprocessor_help', 'analytics'),
+            \core_analytics\manager::default_mlbackend(), $predictors)
         );
 
         // Log store.
index cf9b286..40063a6 100644 (file)
@@ -121,6 +121,9 @@ if ($hassiteconfig or has_any_capability($capabilities, $systemcontext)) {
     $temp->add(new admin_setting_configselect('moodlecourse/showreports', new lang_string('showreports'), '', 0,
         array(0 => new lang_string('no'), 1 => new lang_string('yes'))));
 
+    $temp->add(new admin_setting_configcheckbox('moodlecourse/showcourseimages', get_string('showcourseimages'),
+        get_string('showcourseimages_desc'), 1));
+
     // Files and uploads.
     $temp->add(new admin_setting_heading('filesanduploadshdr', new lang_string('filesanduploads'), ''));
 
@@ -155,7 +158,6 @@ if ($hassiteconfig or has_any_capability($capabilities, $systemcontext)) {
 
     $ADMIN->add('courses', $temp);
 
-
     // "courserequests" settingpage.
     $temp = new admin_settingpage('courserequest', new lang_string('courserequest'));
     $temp->add(new admin_setting_configcheckbox('enablecourserequests', new lang_string('enablecourserequests', 'admin'), new lang_string('configenablecourserequests', 'admin'), 0));
index 66270b2..91fb876 100644 (file)
@@ -72,6 +72,22 @@ class edit_model extends \moodleform {
         $mform->addElement('select', 'timesplitting', get_string('timesplittingmethod', 'analytics'), $timesplittings);
         $mform->addHelpButton('timesplitting', 'timesplittingmethod', 'analytics');
 
+        $defaultprocessor = \core_analytics\manager::get_predictions_processor_name(
+            \core_analytics\manager::get_predictions_processor()
+        );
+        $predictionprocessors = ['' => get_string('defaultpredictoroption', 'analytics', $defaultprocessor)];
+        foreach ($this->_customdata['predictionprocessors'] as $classname => $predictionsprocessor) {
+            if ($predictionsprocessor->is_ready() !== true) {
+                continue;
+            }
+            $optionname = \tool_analytics\output\helper::class_to_option($classname);
+            $predictionprocessors[$optionname] = \core_analytics\manager::get_predictions_processor_name($predictionsprocessor);
+        }
+
+        $mform->addElement('select', 'predictionsprocessor', get_string('predictionsprocessor', 'analytics'),
+            $predictionprocessors);
+        $mform->addHelpButton('predictionsprocessor', 'predictionsprocessor', 'analytics');
+
         $mform->addElement('hidden', 'id', $this->_customdata['id']);
         $mform->setType('id', PARAM_INT);
 
index 58f1129..b70fc24 100644 (file)
@@ -110,7 +110,8 @@ switch ($action) {
             'id' => $model->get_id(),
             'model' => $model,
             'indicators' => $model->get_potential_indicators(),
-            'timesplittings' => \core_analytics\manager::get_enabled_time_splitting_methods()
+            'timesplittings' => \core_analytics\manager::get_enabled_time_splitting_methods(),
+            'predictionprocessors' => \core_analytics\manager::get_all_prediction_processors()
         );
         $mform = new \tool_analytics\output\form\edit_model(null, $customdata);
 
@@ -126,7 +127,8 @@ switch ($action) {
                 $indicators[] = \core_analytics\manager::get_indicator($indicatorclass);
             }
             $timesplitting = \tool_analytics\output\helper::option_to_class($data->timesplitting);
-            $model->update($data->enabled, $indicators, $timesplitting);
+            $predictionsprocessor = \tool_analytics\output\helper::option_to_class($data->predictionsprocessor);
+            $model->update($data->enabled, $indicators, $timesplitting, $predictionsprocessor);
             redirect(new \moodle_url('/admin/tool/analytics/index.php'));
         }
 
@@ -137,6 +139,7 @@ switch ($action) {
         $callable = array('\tool_analytics\output\helper', 'class_to_option');
         $modelobj->indicators = array_map($callable, json_decode($modelobj->indicators));
         $modelobj->timesplitting = \tool_analytics\output\helper::class_to_option($modelobj->timesplitting);
+        $modelobj->predictionsprocessor = \tool_analytics\output\helper::class_to_option($modelobj->predictionsprocessor);
         $mform->set_data($modelobj);
         $mform->display();
         break;
index 7aea934..f2cd279 100644 (file)
  */
 
 $string['aim'] = 'This administration tool helps developers and test writers to create .feature files describing Moodle\'s functionalities and run them automatically. Step definitions available for use in .feature files are listed below.';
-$string['allavailablesteps'] = 'All the available steps definitions';
+$string['allavailablesteps'] = 'All available step definitions';
 $string['errorbehatcommand'] = 'Error running behat CLI command. Try running "{$a} --help" manually from CLI to find out more about the problem.';
 $string['errorcomposer'] = 'Composer dependencies are not installed.';
 $string['errordataroot'] = '$CFG->behat_dataroot is not set or is invalid.';
 $string['errorsetconfig'] = '$CFG->behat_dataroot, $CFG->behat_prefix and $CFG->behat_wwwroot need to be set in config.php.';
 $string['erroruniqueconfig'] = '$CFG->behat_dataroot, $CFG->behat_prefix and $CFG->behat_wwwroot values need to be different than $CFG->dataroot, $CFG->prefix, $CFG->wwwroot, $CFG->phpunit_dataroot and $CFG->phpunit_prefix values.';
 $string['fieldvalueargument'] = 'Field value arguments';
-$string['fieldvalueargument_help'] = 'This argument should be completed by a field value, there are many field types, simple ones like checkboxes, selects or textareas or complex ones like date selectors. You can check <a href="http://docs.moodle.org/dev/Acceptance_testing#Providing_values_to_steps" target="_blank">Field values</a> to see the expected field value depending on the field type you provide.';
+$string['fieldvalueargument_help'] = 'This argument should be completed by a field value. There are many field types, including  simple ones like checkboxes, selects or textareas, or complex ones like date selectors. See the dev documentation <a href="http://docs.moodle.org/dev/Acceptance_testing" target="_blank">Acceptance_testing</a> for details of expected field values.';
 $string['giveninfo'] = 'Given. Processes to set up the environment';
 $string['infoheading'] = 'Info';
 $string['installinfo'] = 'Read {$a} for installation and tests execution info';
-$string['newstepsinfo'] = 'Read {$a} for info about how to add new steps definitions';
+$string['newstepsinfo'] = 'Read {$a} for info about how to add new step definitions';
 $string['newtestsinfo'] = 'Read {$a} for info about how to write new tests';
-$string['nostepsdefinitions'] = 'There aren\'t steps definitions matching this filters';
+$string['nostepsdefinitions'] = 'There aren\'t any step definitions matching this filter';
 $string['pluginname'] = 'Acceptance testing';
 $string['stepsdefinitionscomponent'] = 'Area';
 $string['stepsdefinitionscontains'] = 'Contains';
-$string['stepsdefinitionsfilters'] = 'Steps definitions';
+$string['stepsdefinitionsfilters'] = 'Step definitions';
 $string['stepsdefinitionstype'] = 'Type';
 $string['theninfo'] = 'Then. Checkings to ensure the outcomes are the expected ones';
 $string['unknownexceptioninfo'] = 'There was a problem with Selenium or your browser. Please ensure you are using the latest version of Selenium. Error:';
 $string['viewsteps'] = 'Filter';
-$string['wheninfo'] = 'When. Actions that provokes an event';
+$string['wheninfo'] = 'When. Action that provokes an event';
 $string['wrongbehatsetup'] = 'Something is wrong with the behat setup and so step definitions cannot be listed: <b>{$a->errormsg}</b><br/><br/>Please check:<ul>
 <li>$CFG->behat_dataroot, $CFG->behat_prefix and $CFG->behat_wwwroot are set in config.php with different values from $CFG->dataroot, $CFG->prefix and $CFG->wwwroot.</li>
 <li>You ran "{$a->behatinit}" from your Moodle root directory.</li>
index 2df5363..9ad81f9 100644 (file)
@@ -11,7 +11,7 @@ Feature: List the system steps definitions
 
   @javascript
   Scenario: Accessing the list
-    Then I should see "Steps definitions"
+    Then I should see "Step definitions"
     And I should not see "There aren't steps definitions matching this filter"
 
   @javascript
index 64c094a..0c5ac5f 100644 (file)
@@ -35,7 +35,7 @@ $string['approverequest'] = 'Approve request';
 $string['bulkapproverequests'] = 'Approve requests';
 $string['bulkdenyrequests'] = 'Deny requests';
 $string['cachedef_purpose'] = 'Data purposes';
-$string['cachedef_purpose_overrides'] = 'Purpose overrides in the Data Privacy tool';
+$string['cachedef_purpose_overrides'] = 'Purpose overrides in the Data privacy tool';
 $string['cachedef_contextlevel'] = 'Context levels purpose and category';
 $string['cancelrequest'] = 'Cancel request';
 $string['cancelrequestconfirmation'] = 'Do you really want cancel this data request?';
@@ -77,7 +77,7 @@ $string['dataprivacy:downloadownrequest'] = 'Download your own exported data';
 $string['dataprivacy:downloadallrequests'] = 'Download exported data for everyone';
 $string['dataregistry'] = 'Data registry';
 $string['dataregistryinfo'] = 'The data registry enables categories (types of data) and purposes (the reasons for processing data) to be set for all content on the site - from users and courses down to activities and blocks. For each purpose, a retention period may be set. When a retention period has expired, the data is flagged and listed for deletion, awaiting admin confirmation.';
-$string['dataretentionexplanation'] = 'This summary shows the default categories and purposes for retaining user\'s information on this system. Certain areas of the system may have more specific categories and purposes than those listed here.';
+$string['dataretentionexplanation'] = 'This summary shows the default categories and purposes for retaining user data. Certain areas may have more specific categories and purposes than those listed here.';
 $string['dataretentionsummary'] = 'Data retention summary';
 $string['datarequestcreatedforuser'] = 'Data request created for {$a}';
 $string['datarequestemailsubject'] = 'Data request: {$a}';
@@ -92,7 +92,7 @@ $string['deletecategorytext'] = 'Are you sure you want to delete the category \'
 $string['deletedefaults'] = 'Delete defaults: {$a}';
 $string['deletedefaultsconfirmation'] = 'Are you sure you want to delete the default category and purpose for {$a} modules?';
 $string['deleteexpiredcontextstask'] = 'Delete expired contexts';
-$string['deleteexpireddatarequeststask'] = 'Delete files from completed data requests that have expired';
+$string['deleteexpireddatarequeststask'] = 'Delete expired data request export files';
 $string['deletemyaccount'] = 'Delete my account';
 $string['deletepurpose'] = 'Delete purpose';
 $string['deletepurposetext'] = 'Are you sure you want to delete the purpose \'{$a}\'?';
@@ -235,9 +235,9 @@ $string['requestcomments'] = 'Comments';
 $string['requestcomments_help'] = 'This box enables you to enter any further details about your data request.';
 $string['requestdenied'] = 'The request has been denied';
 $string['requestemailintro'] = 'You have received a data request:';
-$string['requestfor'] = 'Requesting for';
+$string['requestfor'] = 'User';
 $string['requestmarkedcomplete'] = 'The request has been marked as complete';
-$string['requestorigin'] = 'Request origin';
+$string['requestorigin'] = 'Site';
 $string['requestsapproved'] = 'The requests have been approved';
 $string['requestsdenied'] = 'The requests have been denied';
 $string['requeststatus'] = 'Status';
@@ -257,7 +257,7 @@ $string['requireallenddatesforuserdeletion_desc'] = 'When calculating user expir
 * the user\'s last login time is compared against the retention period for users; and
 * whether the user is actively enrolled in any courses.
 
-When checking the active enrolment of a corse, if the course has no end date then this setting is used to determine whether that course is considered active or not.
+When checking the active enrolment in a course, if the course has no end date then this setting is used to determine whether that course is considered active or not.
 
 If the course has no end date, and this setting is enabled, then the user cannot be deleted.';
 $string['requiresattention'] = 'Requires attention.';
@@ -310,7 +310,7 @@ $string['tobedeleted'] = 'Data to be deleted';
 $string['addroleoverride'] = 'Add role override';
 $string['roleoverride'] = 'Role override';
 $string['role'] = 'Role';
-$string['role_help'] = 'Which role do you wish to apply this override to';
+$string['role_help'] = 'The role which the override should apply to.';
 $string['duplicaterole'] = 'Role already specified';
 $string['purposeoverview'] = 'A purpose describes the intended use and retention policy for stored data. The basis for storing and retaining that data is also described in the purpose.';
 $string['roleoverrideoverview'] = 'The default retention policy can be overridden for specific user roles, allowing you to specify a longer, or a shorter, retention policy. A user is only expired when all of their roles have expired.';
index f16a006..99daa14 100644 (file)
@@ -30,7 +30,7 @@ Feature: Data delete from the privacy API
     And I log in as "admin"
     And I navigate to "Users > Privacy and policies > Data requests" in site administration
     And I follow "New request"
-    And I set the field "Requesting for" to "Victim User 1"
+    And I set the field "User" to "Victim User 1"
     And I set the field "Type" to "Delete all of my personal data"
     And I press "Save changes"
     Then I should see "Victim User 1"
@@ -93,7 +93,7 @@ Feature: Data delete from the privacy API
     And I follow "Profile" in the user menu
     And I follow "Data requests"
     And I follow "New request"
-    And I set the field "Requesting for" to "Victim User 1"
+    And I set the field "User" to "Victim User 1"
     And I set the field "Type" to "Delete all of my personal data"
     And I press "Save changes"
     Then I should see "Victim User 1"
index 7f87c10..b2a8235 100644 (file)
@@ -27,7 +27,7 @@ Feature: Data export from the privacy API
     Given I log in as "admin"
     And I navigate to "Users > Privacy and policies > Data requests" in site administration
     And I follow "New request"
-    And I set the field "Requesting for" to "Victim User 1"
+    And I set the field "User" to "Victim User 1"
     And I press "Save changes"
     Then I should see "Victim User 1"
     And I should see "Pending" in the "Victim User 1" "table_row"
@@ -96,7 +96,7 @@ Feature: Data export from the privacy API
     And I follow "Profile" in the user menu
     And I follow "Data requests"
     And I follow "New request"
-    And I set the field "Requesting for" to "Victim User 1"
+    And I set the field "User" to "Victim User 1"
     And I press "Save changes"
     Then I should see "Victim User 1"
     And I should see "Pending" in the "Victim User 1" "table_row"
index f09718c..8de80cd 100644 (file)
@@ -42,7 +42,7 @@ $string['aisrequired'] = '\'{$a}\' is required';
 $string['aplanswerecreated'] = '{$a} learning plans were created.';
 $string['aplanswerecreatedmoremayrequiresync'] = '{$a} learning plans were created; more will be created during the next synchronisation.';
 $string['assigncohorts'] = 'Assign cohorts';
-$string['averageproficiencyrate'] = 'The average proficiency rate for completed learning plans based on this template is {$a} %';
+$string['averageproficiencyrate'] = 'The average proficiency rate for completed learning plans based on this template is {$a}%.';
 $string['cancelreviewrequest'] = 'Cancel review request';
 $string['cannotaddrules'] = 'This competency cannot be configured.';
 $string['cannotcreateuserplanswhentemplateduedateispassed'] = 'New learning plans cannot be created. The template due date has expired, or is about to expire.';
@@ -79,7 +79,7 @@ $string['coursecompetencyratingsarenotpushedtouserplans'] = 'Competency ratings
 $string['coursecompetencyratingsarepushedtouserplans'] = 'Competency ratings in this course are updated immediately in learning plans.';
 $string['coursecompetencyratingsquestion'] = 'When a course competency is rated, does the rating update the competency in the learning plans, or is it only applied to the course?';
 $string['coursesusingthiscompetency'] = 'Courses linked to this competency';
-$string['coveragesummary'] = '{$a->competenciescoveredcount} of {$a->competenciescount} competencies are covered ( {$a->coveragepercentage} % )';
+$string['coveragesummary'] = '{$a->competenciescoveredcount} of {$a->competenciescount} competencies are covered ( {$a->coveragepercentage}% )';
 $string['createplans'] = 'Create learning plans';
 $string['createlearningplans'] = 'Create learning plans';
 $string['crossreferencedcompetencies'] = 'Cross-referenced competencies';
index 53be916..33dc481 100644 (file)
@@ -30,7 +30,9 @@ use context;
 use context_user;
 use core_privacy\local\metadata\collection;
 use core_privacy\local\request\approved_contextlist;
+use core_privacy\local\request\approved_userlist;
 use core_privacy\local\request\transform;
+use core_privacy\local\request\userlist;
 use core_privacy\local\request\writer;
 
 /**
@@ -43,6 +45,7 @@ use core_privacy\local\request\writer;
  */
 class provider implements
     \core_privacy\local\metadata\provider,
+    \core_privacy\local\request\core_userlist_provider,
     \core_privacy\local\request\plugin\provider {
 
     /**
@@ -81,6 +84,30 @@ class provider implements
         return $contextlist;
     }
 
+    /**
+     * Get the list of users who have data within a context.
+     *
+     * @param   userlist    $userlist   The userlist containing the list of users who have data in this context/plugin combination.
+     */
+    public static function get_users_in_context(userlist $userlist) {
+        global $DB;
+
+        $context = $userlist->get_context();
+
+        if (!is_a($context, \context_user::class)) {
+            return;
+        }
+
+        // Add user if any messagelist data exists.
+        if ($DB->record_exists('messageinbound_messagelist', ['userid' => $context->instanceid])) {
+            // Only using user context, so instance ID will be the only user ID.
+            $userlist->add_user($context->instanceid);
+        }
+
+        // Add users based on userkey (since we also delete those).
+        \core_userkey\privacy\provider::get_user_contexts_with_script($userlist, $context, 'messageinbound_handler');
+    }
+
     /**
      * Export all user data for the specified user, in the specified contexts.
      *
@@ -142,6 +169,23 @@ class provider implements
         static::delete_user_data($contextlist->get_user()->id);
     }
 
+    /**
+     * Delete multiple users within a single context.
+     *
+     * @param   approved_userlist       $userlist The approved context and user information to delete information for.
+     */
+    public static function delete_data_for_users(approved_userlist $userlist) {
+        $context = $userlist->get_context();
+        $userids = $userlist->get_userids();
+
+        // Since this falls within a user context, only that user should be valid.
+        if ($context->contextlevel != CONTEXT_USER || count($userids) != 1 || $context->instanceid != $userids[0]) {
+            return;
+        }
+
+        static::delete_user_data($userids[0]);
+    }
+
     /**
      * Delete a user's data.
      *
index 5be172c..e98c027 100644 (file)
@@ -29,7 +29,9 @@ global $CFG;
 
 use core_privacy\tests\provider_testcase;
 use core_privacy\local\request\approved_contextlist;
+use core_privacy\local\request\approved_userlist;
 use core_privacy\local\request\transform;
+use core_privacy\local\request\userlist;
 use core_privacy\local\request\writer;
 use tool_messageinbound\privacy\provider;
 
@@ -70,6 +72,51 @@ class tool_messageinbound_privacy_testcase extends provider_testcase {
         $this->assertEquals($u2ctx->id, $contexts[0]->id);
     }
 
+    /**
+     * Test for provider::test_get_users_in_context().
+     */
+    public function test_get_users_in_context() {
+        $component = 'tool_messageinbound';
+        $dg = $this->getDataGenerator();
+        $u1 = $dg->create_user();
+        $u2 = $dg->create_user();
+        $u3 = $dg->create_user();
+        $u1ctx = context_user::instance($u1->id);
+        $u2ctx = context_user::instance($u2->id);
+        $u3ctx = context_user::instance($u3->id);
+
+        $addressmanager = new \core\message\inbound\address_manager();
+        $addressmanager->set_handler('\tool_messageinbound\message\inbound\invalid_recipient_handler');
+        $addressmanager->set_data(123);
+
+        // Create a user key for user 1.
+        $addressmanager->generate($u1->id);
+
+        // Create a messagelist for user 2.
+        $this->create_messagelist(['userid' => $u2->id, 'address' => 'u2@example1.com']);
+
+        $userlist1 = new userlist($u1ctx, $component);
+        provider::get_users_in_context($userlist1);
+        $userlist2 = new userlist($u2ctx, $component);
+        provider::get_users_in_context($userlist2);
+        $userlist3 = new userlist($u3ctx, $component);
+        provider::get_users_in_context($userlist3);
+
+        // Ensure user 1 is found from userkey.
+        $userids = $userlist1->get_userids();
+        $this->assertCount(1, $userids);
+        $this->assertEquals($u1->id, $userids[0]);
+
+        // Ensure user 2 is found from messagelist.
+        $userids = $userlist2->get_userids();
+        $this->assertCount(1, $userids);
+        $this->assertEquals($u2->id, $userids[0]);
+
+        // User 3 has neither, so should not be found.
+        $userids = $userlist3->get_userids();
+        $this->assertCount(0, $userids);
+    }
+
     public function test_delete_data_for_user() {
         global $DB;
         $dg = $this->getDataGenerator();
@@ -110,6 +157,58 @@ class tool_messageinbound_privacy_testcase extends provider_testcase {
         $this->assertTrue($DB->record_exists('messageinbound_messagelist', ['userid' => $u2->id]));
     }
 
+    /**
+     * Test for provider::test_delete_data_for_users().
+     */
+    public function test_delete_data_for_users() {
+        global $DB;
+        $component = 'tool_messageinbound';
+        $dg = $this->getDataGenerator();
+        $u1 = $dg->create_user();
+        $u2 = $dg->create_user();
+        $u1ctx = context_user::instance($u1->id);
+        $u2ctx = context_user::instance($u2->id);
+
+        $addressmanager = new \core\message\inbound\address_manager();
+        $addressmanager->set_handler('\tool_messageinbound\message\inbound\invalid_recipient_handler');
+        $addressmanager->set_data(123);
+
+        // Create a user key for both users.
+        $addressmanager->generate($u1->id);
+        $addressmanager->generate($u2->id);
+
+        // Create a messagelist for both users.
+        $this->create_messagelist(['userid' => $u1->id]);
+        $this->create_messagelist(['userid' => $u2->id]);
+
+        // Ensure data exists for both users.
+        $this->assertTrue($DB->record_exists('user_private_key', ['userid' => $u1->id, 'script' => 'messageinbound_handler']));
+        $this->assertTrue($DB->record_exists('user_private_key', ['userid' => $u2->id, 'script' => 'messageinbound_handler']));
+        $this->assertTrue($DB->record_exists('messageinbound_messagelist', ['userid' => $u1->id]));
+        $this->assertTrue($DB->record_exists('messageinbound_messagelist', ['userid' => $u2->id]));
+
+        // Ensure passing another user's ID does not do anything.
+        $approveduserids = [$u2->id];
+        $approvedlist = new approved_userlist($u1ctx, $component, $approveduserids);
+        provider::delete_data_for_users($approvedlist);
+
+        $this->assertTrue($DB->record_exists('user_private_key', ['userid' => $u1->id, 'script' => 'messageinbound_handler']));
+        $this->assertTrue($DB->record_exists('user_private_key', ['userid' => $u2->id, 'script' => 'messageinbound_handler']));
+        $this->assertTrue($DB->record_exists('messageinbound_messagelist', ['userid' => $u1->id]));
+        $this->assertTrue($DB->record_exists('messageinbound_messagelist', ['userid' => $u2->id]));
+
+        // Delete u1's data.
+        $approveduserids = [$u1->id];
+        $approvedlist = new approved_userlist($u1ctx, $component, $approveduserids);
+        provider::delete_data_for_users($approvedlist);
+
+        // Confirm only u1's data is deleted.
+        $this->assertFalse($DB->record_exists('user_private_key', ['userid' => $u1->id, 'script' => 'messageinbound_handler']));
+        $this->assertTrue($DB->record_exists('user_private_key', ['userid' => $u2->id, 'script' => 'messageinbound_handler']));
+        $this->assertFalse($DB->record_exists('messageinbound_messagelist', ['userid' => $u1->id]));
+        $this->assertTrue($DB->record_exists('messageinbound_messagelist', ['userid' => $u2->id]));
+    }
+
     public function test_delete_data_for_all_users_in_context() {
         global $DB;
         $dg = $this->getDataGenerator();
index cdf787e..ddc70e0 100644 (file)
@@ -35,6 +35,11 @@ defined('MOODLE_INTERNAL') || die();
  */
 class manager {
 
+    /**
+     * Default mlbackend
+     */
+    const DEFAULT_MLBACKEND = '\mlbackend_php\processor';
+
     /**
      * @var \core_analytics\predictor[]
      */
@@ -117,9 +122,9 @@ class manager {
     }
 
     /**
-     * Returns the site selected predictions processor.
+     * Returns the provided predictions processor class.
      *
-     * @param string $predictionclass
+     * @param false|string $predictionclass Returns the system default processor if false
      * @param bool $checkisready
      * @return \core_analytics\predictor
      */
@@ -128,13 +133,13 @@ class manager {
         // We want 0 or 1 so we can use it as an array key for caching.
         $checkisready = intval($checkisready);
 
-        if ($predictionclass === false) {
+        if (!$predictionclass) {
             $predictionclass = get_config('analytics', 'predictionsprocessor');
         }
 
         if (empty($predictionclass)) {
             // Use the default one if nothing set.
-            $predictionclass = '\mlbackend_php\processor';
+            $predictionclass = self::default_mlbackend();
         }
 
         if (!class_exists($predictionclass)) {
@@ -179,6 +184,44 @@ class manager {
         return $predictionprocessors;
     }
 
+    /**
+     * Returns the name of the provided predictions processor.
+     *
+     * @param \core_analytics\predictor $predictionsprocessor
+     * @return string
+     */
+    public static function get_predictions_processor_name(\core_analytics\predictor $predictionsprocessor) {
+            $component = substr(get_class($predictionsprocessor), 0, strpos(get_class($predictionsprocessor), '\\', 1));
+        return get_string('pluginname', $component);
+    }
+
+    /**
+     * Whether the provided plugin is used by any model.
+     *
+     * @param string $plugin
+     * @return bool
+     */
+    public static function is_mlbackend_used($plugin) {
+        $models = self::get_all_models();
+        foreach ($models as $model) {
+            $processor = $model->get_predictions_processor();
+            $noprefixnamespace = ltrim(get_class($processor), '\\');
+            $processorplugin = substr($noprefixnamespace, 0, strpos($noprefixnamespace, '\\'));
+            if ($processorplugin == $plugin) {
+                return true;
+            }
+        }
+
+        // Default predictions processor.
+        $defaultprocessorclass = get_config('analytics', 'predictionsprocessor');
+        $pluginclass = '\\' . $plugin . '\\processor';
+        if ($pluginclass === $defaultprocessorclass) {
+            return true;
+        }
+
+        return false;
+    }
+
     /**
      * Get all available time splitting methods.
      *
@@ -546,6 +589,15 @@ class manager {
         }
     }
 
+    /**
+     * Default system backend.
+     *
+     * @return string
+     */
+    public static function default_mlbackend() {
+        return self::DEFAULT_MLBACKEND;
+    }
+
     /**
      * Returns the provided element classes in the site.
      *
index 0bee650..199baf4 100644 (file)
@@ -110,6 +110,11 @@ class model {
      */
     protected $target = null;
 
+    /**
+     * @var \core_analytics\predictor
+     */
+    protected $predictionsprocessor = null;
+
     /**
      * @var \core_analytics\local\indicator\base[]
      */
@@ -336,7 +341,8 @@ class model {
      * @param string $timesplittingid The time splitting method id (its fully qualified class name)
      * @return \core_analytics\model
      */
-    public static function create(\core_analytics\local\target\base $target, array $indicators, $timesplittingid = false) {
+    public static function create(\core_analytics\local\target\base $target, array $indicators,
+                                  $timesplittingid = false, $processor = false) {
         global $USER, $DB;
 
         \core_analytics\manager::check_can_manage_models();
@@ -353,6 +359,14 @@ class model {
         $modelobj->timemodified = $now;
         $modelobj->usermodified = $USER->id;
 
+        if ($processor &&
+                !self::is_valid($processor, '\core_analytics\classifier') &&
+                !self::is_valid($processor, '\core_analytics\regressor')) {
+            throw new \coding_exception('The provided predictions processor \\' . $processor . '\processor is not valid');
+        } else {
+            $modelobj->predictionsprocessor = $processor;
+        }
+
         $id = $DB->insert_record('analytics_models', $modelobj);
 
         // Get db defaults.
@@ -411,9 +425,10 @@ class model {
      * @param int|bool $enabled
      * @param \core_analytics\local\indicator\base[]|false $indicators False to respect current indicators
      * @param string|false $timesplittingid False to respect current time splitting method
+     * @param string|false $predictionsprocessor False to respect current predictors processor value
      * @return void
      */
-    public function update($enabled, $indicators = false, $timesplittingid = '') {
+    public function update($enabled, $indicators = false, $timesplittingid = '', $predictionsprocessor = false) {
         global $USER, $DB;
 
         \core_analytics\manager::check_can_manage_models();
@@ -433,8 +448,14 @@ class model {
             $timesplittingid = $this->model->timesplitting;
         }
 
+        if ($predictionsprocessor === false) {
+            // Respect current value.
+            $predictionsprocessor = $this->model->predictionsprocessor;
+        }
+
         if ($this->model->timesplitting !== $timesplittingid ||
-                $this->model->indicators !== $indicatorsstr) {
+                $this->model->indicators !== $indicatorsstr ||
+                $this->model->predictionsprocessor !== $predictionsprocessor) {
 
             // Delete generated predictions before changing the model version.
             $this->clear();
@@ -458,6 +479,7 @@ class model {
         $this->model->enabled = intval($enabled);
         $this->model->indicators = $indicatorsstr;
         $this->model->timesplitting = $timesplittingid;
+        $this->model->predictionsprocessor = $predictionsprocessor;
         $this->model->timemodified = $now;
         $this->model->usermodified = $USER->id;
 
@@ -477,8 +499,14 @@ class model {
         $this->clear();
 
         // Method self::clear is already clearing the current model version.
-        $predictor = \core_analytics\manager::get_predictions_processor();
-        $predictor->delete_output_dir($this->get_output_dir(array(), true));
+        $predictor = $this->get_predictions_processor(false);
+        if ($predictor->is_ready() !== true) {
+            $predictorname = \core_analytics\manager::get_predictions_processor_name($predictor);
+            debugging('Prediction processor ' . $predictorname . ' is not ready to be used. Model ' .
+                $this->model->id . ' could not be deleted.');
+        } else {
+            $predictor->delete_output_dir($this->get_output_dir(array(), true));
+        }
 
         $DB->delete_records('analytics_models', array('id' => $this->model->id));
         $DB->delete_records('analytics_models_log', array('modelid' => $this->model->id));
@@ -516,7 +544,7 @@ class model {
         $this->heavy_duty_mode();
 
         // Before get_labelled_data call so we get an early exception if it is not ready.
-        $predictor = \core_analytics\manager::get_predictions_processor();
+        $predictor = $this->get_predictions_processor();
 
         $datasets = $this->get_analyser()->get_labelled_data();
 
@@ -608,7 +636,7 @@ class model {
         $outputdir = $this->get_output_dir(array('execution'));
 
         // Before get_labelled_data call so we get an early exception if it is not ready.
-        $predictor = \core_analytics\manager::get_predictions_processor();
+        $predictor = $this->get_predictions_processor();
 
         $datasets = $this->get_analyser()->get_labelled_data();
 
@@ -677,7 +705,7 @@ class model {
 
         // Before get_unlabelled_data call so we get an early exception if it is not ready.
         if (!$this->is_static()) {
-            $predictor = \core_analytics\manager::get_predictions_processor();
+            $predictor = $this->get_predictions_processor();
         }
 
         $samplesdata = $this->get_analyser()->get_unlabelled_data();
@@ -738,6 +766,16 @@ class model {
         return $result;
     }
 
+    /**
+     * Returns the model predictions processor.
+     *
+     * @param bool $checkisready
+     * @return \core_analytics\predictor
+     */
+    public function get_predictions_processor($checkisready = true) {
+        return manager::get_predictions_processor($this->model->predictionsprocessor, $checkisready);
+    }
+
     /**
      * Formats the predictor results.
      *
@@ -1457,8 +1495,14 @@ class model {
         \core_analytics\manager::check_can_manage_models();
 
         // Delete current model version stored stuff.
-        $predictor = \core_analytics\manager::get_predictions_processor();
-        $predictor->clear_model($this->get_unique_id(), $this->get_output_dir());
+        $predictor = $this->get_predictions_processor(false);
+        if ($predictor->is_ready() !== true) {
+            $predictorname = \core_analytics\manager::get_predictions_processor_name($predictor);
+            debugging('Prediction processor ' . $predictorname . ' is not ready to be used. Model ' .
+                $this->model->id . ' could not be cleared.');
+        } else {
+            $predictor->clear_model($this->get_unique_id(), $this->get_output_dir());
+        }
 
         $predictionids = $DB->get_fieldset_select('analytics_predictions', 'id', 'modelid = :modelid',
             array('modelid' => $this->get_id()));
index 39c2b98..34d82f7 100644 (file)
@@ -143,10 +143,8 @@ class core_analytics_prediction_testcase extends advanced_testcase {
             $this->markTestSkipped('Skipping ' . $predictionsprocessorclass . ' as the predictor is not ready.');
         }
 
-        set_config('predictionsprocessor', $predictionsprocessorclass, 'analytics');
-
         $model = $this->add_perfect_model();
-        $model->enable($timesplittingid);
+        $model->update(true, false, $timesplittingid, get_class($predictionsprocessor));
 
         // No samples trained yet.
         $this->assertEquals(0, $DB->count_records('analytics_train_samples', array('modelid' => $model->get_id())));
@@ -423,8 +421,7 @@ class core_analytics_prediction_testcase extends advanced_testcase {
             $this->markTestSkipped('Skipping ' . $predictionsprocessorclass . ' as the predictor is not ready.');
         }
 
-        set_config('predictionsprocessor', $predictionsprocessorclass, 'analytics');
-
+        $model->update(false, false, false, get_class($predictionsprocessor));
         $results = $model->evaluate();
 
         // We check that the returned status includes at least $expectedcode code.
index 7c08349..2cb651d 100644 (file)
@@ -336,4 +336,95 @@ class core_auth_external extends external_api {
             )
         );
     }
+
+    /**
+     * Describes the parameters for resend_confirmation_email.
+     *
+     * @return external_function_parameters
+     * @since Moodle 3.6
+     */
+    public static function resend_confirmation_email_parameters() {
+        return new external_function_parameters(
+            array(
+                'username' => new external_value(core_user::get_property_type('username'), 'Username.'),
+                'password' => new external_value(core_user::get_property_type('password'), 'Plain text password.'),
+                'redirect' => new external_value(PARAM_LOCALURL, 'Redirect the user to this site url after confirmation.',
+                    VALUE_DEFAULT, ''),
+            )
+        );
+    }
+
+    /**
+     * Requests resend the confirmation email.
+     *
+     * @param  string $username user name
+     * @param  string $password plain text password
+     * @param  string $redirect redirect the user to this site url after confirmation
+     * @return array warnings and success status
+     * @since Moodle 3.6
+     * @throws moodle_exception
+     */
+    public static function resend_confirmation_email($username, $password, $redirect = '') {
+        global $PAGE;
+
+        $warnings = array();
+        $params = self::validate_parameters(
+            self::resend_confirmation_email_parameters(),
+            array(
+                'username' => $username,
+                'password' => $password,
+                'redirect' => $redirect,
+            )
+        );
+
+        $context = context_system::instance();
+        $PAGE->set_context($context);   // Need by internal APIs.
+        $username = trim(core_text::strtolower($params['username']));
+        $password = $params['password'];
+
+        if (is_restored_user($username)) {
+            throw new moodle_exception('restoredaccountresetpassword', 'webservice');
+        }
+
+        $user = authenticate_user_login($username, $password);
+
+        if (empty($user)) {
+            throw new moodle_exception('invalidlogin');
+        }
+
+        if ($user->confirmed) {
+            throw new moodle_exception('alreadyconfirmed');
+        }
+
+        // Check if we should redirect the user once the user is confirmed.
+        $confirmationurl = null;
+        if (!empty($params['redirect'])) {
+            // Pass via moodle_url to fix thinks like admin links.
+            $redirect = new moodle_url($params['redirect']);
+
+            $confirmationurl = new moodle_url('/login/confirm.php', array('redirect' => $redirect->out()));
+        }
+        $status = send_confirmation_email($user, $confirmationurl);
+
+        return array(
+            'status' => $status,
+            'warnings' => $warnings,
+        );
+    }
+
+    /**
+     * Describes the resend_confirmation_email return value.
+     *
+     * @return external_single_structure
+     * @since Moodle 3.6
+     */
+    public static function resend_confirmation_email_returns() {
+
+        return new external_single_structure(
+            array(
+                'status' => new external_value(PARAM_BOOL, 'True if the confirmation email was sent, false otherwise.'),
+                'warnings'  => new external_warnings(),
+            )
+        );
+    }
 }
index 973c7d7..584abb8 100644 (file)
@@ -78,7 +78,7 @@ class login implements renderable, templatable {
      * @param string $username The username to display.
      */
     public function __construct(array $authsequence, $username = '') {
-        global $CFG, $SESSION;
+        global $CFG;
 
         $this->username = $username;
 
@@ -87,12 +87,13 @@ class login implements renderable, templatable {
         $this->cansignup = $CFG->registerauth == 'email' || !empty($CFG->registerauth);
         if ($CFG->rememberusername == 0) {
             $this->cookieshelpicon = new help_icon('cookiesenabledonlysession', 'core');
+            $this->rememberusername = false;
         } else {
             $this->cookieshelpicon = new help_icon('cookiesenabled', 'core');
+            $this->rememberusername = true;
         }
 
         $this->autofocusform = !empty($CFG->loginpageautofocus);
-        $this->rememberusername = isset($CFG->rememberusername) and $CFG->rememberusername == 2;
 
         $this->forgotpasswordurl = new moodle_url('/login/forgot_password.php');
         $this->loginurl = new moodle_url('/login/index.php');
index 8f77a3c..c6c0145 100644 (file)
@@ -59,7 +59,7 @@ $string['auth_dbsybasequotinghelp'] = 'Sybase style single quote escaping - need
 $string['auth_dbsyncuserstask'] = 'Synchronise users task';
 $string['auth_dbtable'] = 'Name of the table in the database';
 $string['auth_dbtable_key'] = 'Table';
-$string['auth_dbtype'] = 'The database type (See the <a href="http://phplens.com/adodb/supported.databases.html" target="_blank">ADOdb documentation</a> for details)';
+$string['auth_dbtype'] = 'The database type (see the documentation <a href="http://adodb.org/dokuwiki/doku.php" target="_blank">ADOdb - Database Abstraction Layer for PHP</a> for details).';
 $string['auth_dbtype_key'] = 'Database';
 $string['auth_dbupdateusers'] = 'Update users';
 $string['auth_dbupdateusers_description'] = 'As well as inserting new users, update existing users.';
index 6074f71..06f36e7 100644 (file)
@@ -86,7 +86,7 @@ $string['privacy:metadata:mnet_log:coursename'] = 'Remote system course full nam
 $string['privacy:metadata:mnet_log:hostid'] = 'Remote system MNet ID.';
 $string['privacy:metadata:mnet_log:info'] = 'Additional information about the action.';
 $string['privacy:metadata:mnet_log:ip'] = 'The IP address used at the time of the action occurred.';
-$string['privacy:metadata:mnet_log:module'] = 'Remote system module where the event the action occurred.';
+$string['privacy:metadata:mnet_log:module'] = 'Remote system module where the action occurred.';
 $string['privacy:metadata:mnet_log:remoteid'] = 'Remote ID of the user who carried out the action in the remote system.';
 $string['privacy:metadata:mnet_log:time'] = 'Time when the action occurred.';
 $string['privacy:metadata:mnet_log:url'] = 'Remote system URL where the action occurred.';
index 6970800..f80390b 100644 (file)
@@ -39,3 +39,18 @@ Feature: Authentication
     Given I log in as "admin"
     When I log out
     Then I should see "You are not logged in" in the "page-footer" "region"
+
+  Scenario Outline: Checking the display of the Remember username checkbox
+    Given I log in as "admin"
+    And I set the following administration settings values:
+      | rememberusername | <settingvalue> |
+    And I log out
+    And I am on homepage
+    When I click on "Log in" "link" in the ".logininfo" "css_element"
+    Then I should <expect> "Remember username"
+
+    Examples:
+      | settingvalue | expect  |
+      | 0            | not see |
+      | 1            | see     |
+      | 2            | see     |
index 9f04967..8782f89 100644 (file)
@@ -35,11 +35,11 @@ Feature: Test the 'Digital age of consent verification' feature works.
     When I set the field "What is your age?" to "12"
     And I set the field "In which country do you live?" to "AT"
     And I press "Proceed"
-    Then I should see "You are considered to be a digital minor."
-    And I should see "To create an account on this site please have your parent/guardian contact the following person."
+    Then I should see "You are too young to create an account on this site."
+    And I should see "Please ask your parent/guardian to contact:"
     # Try to access the sign up page again.
     When I click on "Back to the site home" "link"
     And I click on "Log in" "link" in the ".logininfo" "css_element"
     And I press "Create new account"
-    Then I should see "You are considered to be a digital minor."
-    And I should see "To create an account on this site please have your parent/guardian contact the following person."
+    Then I should see "You are too young to create an account on this site."
+    And I should see "Please ask your parent/guardian to contact:"
index 84ffa1f..237852c 100644 (file)
@@ -40,6 +40,9 @@ require_once($CFG->dirroot . '/webservice/tests/helpers.php');
  */
 class core_auth_external_testcase extends externallib_advanced_testcase {
 
+    /** @var string Original error log */
+    protected $oldlog;
+
     /**
      * Set up for every test
      */
@@ -48,6 +51,18 @@ class core_auth_external_testcase extends externallib_advanced_testcase {
 
         $this->resetAfterTest(true);
         $CFG->registerauth = 'email';
+
+        // Discard error logs.
+        $this->oldlog = ini_get('error_log');
+        ini_set('error_log', "$CFG->dataroot/testlog.log");
+    }
+
+    /**
+     * Tear down to restore old logging..
+     */
+    protected function tearDown() {
+        ini_set('error_log', $this->oldlog);
+        parent::tearDown();
     }
 
     /**
@@ -115,4 +130,105 @@ class core_auth_external_testcase extends externallib_advanced_testcase {
             core_auth_external::is_age_digital_consent_verification_enabled_returns(), $result);
         $this->assertTrue($result['status']);
     }
+
+    /**
+     * Test resend_confirmation_email.
+     */
+    public function test_resend_confirmation_email() {
+        global $DB;
+
+        $username = 'pepe';
+        $password = 'abcdefAª.ªª!!3';
+        $firstname = 'Pepe';
+        $lastname = 'Pérez';
+        $email = 'myemail@no.zbc';
+
+        // Create new user.
+        $result = auth_email_external::signup_user($username, $password, $firstname, $lastname, $email);
+        $result = external_api::clean_returnvalue(auth_email_external::signup_user_returns(), $result);
+        $this->assertTrue($result['success']);
+        $this->assertEmpty($result['warnings']);
+
+        $result = core_auth_external::resend_confirmation_email($username, $password);
+        $result = external_api::clean_returnvalue(core_auth_external::resend_confirmation_email_returns(), $result);
+        $this->assertTrue($result['status']);
+        $this->assertEmpty($result['warnings']);
+        $confirmed = $DB->get_field('user', 'confirmed', array('username' => $username));
+        $this->assertEquals(0, $confirmed);
+    }
+
+    /**
+     * Test resend_confirmation_email invalid username.
+     */
+    public function test_resend_confirmation_email_invalid_username() {
+
+        $username = 'pepe';
+        $password = 'abcdefAª.ªª!!3';
+        $firstname = 'Pepe';
+        $lastname = 'Pérez';
+        $email = 'myemail@no.zbc';
+
+        // Create new user.
+        $result = auth_email_external::signup_user($username, $password, $firstname, $lastname, $email);
+        $result = external_api::clean_returnvalue(auth_email_external::signup_user_returns(), $result);
+        $this->assertTrue($result['success']);
+        $this->assertEmpty($result['warnings']);
+
+        $_SERVER['HTTP_USER_AGENT'] = 'no browser'; // Hack around missing user agent in CLI scripts.
+        $this->expectException('moodle_exception');
+        $this->expectExceptionMessage('error/invalidlogin');
+        $result = core_auth_external::resend_confirmation_email('abc', $password);
+    }
+
+    /**
+     * Test resend_confirmation_email invalid password.
+     */
+    public function test_resend_confirmation_email_invalid_password() {
+
+        $username = 'pepe';
+        $password = 'abcdefAª.ªª!!3';
+        $firstname = 'Pepe';
+        $lastname = 'Pérez';
+        $email = 'myemail@no.zbc';
+
+        // Create new user.
+        $result = auth_email_external::signup_user($username, $password, $firstname, $lastname, $email);
+        $result = external_api::clean_returnvalue(auth_email_external::signup_user_returns(), $result);
+        $this->assertTrue($result['success']);
+        $this->assertEmpty($result['warnings']);
+
+        $_SERVER['HTTP_USER_AGENT'] = 'no browser'; // Hack around missing user agent in CLI scripts.
+        $this->expectException('moodle_exception');
+        $this->expectExceptionMessage('error/invalidlogin');
+        $result = core_auth_external::resend_confirmation_email($username, 'abc');
+    }
+
+    /**
+     * Test resend_confirmation_email already confirmed user.
+     */
+    public function test_resend_confirmation_email_already_confirmed_user() {
+        global $DB;
+
+        $username = 'pepe';
+        $password = 'abcdefAª.ªª!!3';
+        $firstname = 'Pepe';
+        $lastname = 'Pérez';
+        $email = 'myemail@no.zbc';
+
+        // Create new user.
+        $result = auth_email_external::signup_user($username, $password, $firstname, $lastname, $email);
+        $result = external_api::clean_returnvalue(auth_email_external::signup_user_returns(), $result);
+        $this->assertTrue($result['success']);
+        $this->assertEmpty($result['warnings']);
+        $secret = $DB->get_field('user', 'secret', array('username' => $username));
+
+        // Confirm the user.
+        $result = core_auth_external::confirm_user($username, $secret);
+        $result = external_api::clean_returnvalue(core_auth_external::confirm_user_returns(), $result);
+        $this->assertTrue($result['success']);
+
+        $this->expectException('moodle_exception');
+        $this->expectExceptionMessage('error/alreadyconfirmed');
+        core_auth_external::resend_confirmation_email($username, $password);
+    }
 }
index aa73738..c78dace 100644 (file)
@@ -23,7 +23,7 @@
  */
 
 $string['conditiontitle'] = 'User profile field';
-$string['description'] = 'Control access based on fields within the student&rsquo;s profile.';
+$string['description'] = 'Control access based on fields within the student\'s profile.';
 $string['error_selectfield'] = 'You must select a profile field.';
 $string['error_setvalue'] = 'You must type a value.';
 $string['label_operator'] = 'Method of comparison';
@@ -42,7 +42,7 @@ $string['requires_startswith'] = 'Your <strong>{$a->field}</strong> starts with
 $string['missing'] = '(Missing custom field: {$a})';
 $string['title'] = 'User profile';
 $string['op_contains'] = 'contains';
-$string['op_doesnotcontain'] = 'doesn&rsquo;t contain';
+$string['op_doesnotcontain'] = 'doesn\'t contain';
 $string['op_endswith'] = 'ends with';
 $string['op_isempty'] = 'is empty';
 $string['op_isequalto'] = 'is equal to';
index 6de059d..9500656 100644 (file)
@@ -61,9 +61,9 @@ $string['myoverview:myaddinstance'] = 'Add a new course overview block to Dashbo
 $string['nocourses'] = 'No courses';
 $string['past'] = 'Past';
 $string['pluginname'] = 'Course overview';
-$string['privacy:metadata:overviewsortpreference'] = 'The myoverview block sort preference.';
-$string['privacy:metadata:overviewviewpreference'] = 'The myoverview block view preference.';
-$string['privacy:metadata:overviewgroupingpreference'] = 'The myoverview block grouping preference.';
+$string['privacy:metadata:overviewsortpreference'] = 'The Course overview block sort preference.';
+$string['privacy:metadata:overviewviewpreference'] = 'The Course overview block view preference.';
+$string['privacy:metadata:overviewgroupingpreference'] = 'The Course overview block grouping preference.';
 $string['removefromfavourites'] = 'Unstar this course';
 $string['summary'] = 'Summary';
 $string['title'] = 'Title';
index 73779ee..0470ca4 100644 (file)
@@ -42,8 +42,9 @@
                 <div class="position-absolute">
                     {{> block_myoverview/favourite-icon }}
                 </div>
-                <img src="{{{courseimage}}}" class="summaryimage img-fluid" alt="{{#str}}aria:courseimage, block_myoverview{{/str}}">
-
+                <div class="card-img-top summaryimage" style='background-image: url("{{{courseimage}}}");'>
+                    <span class="sr-only">{{#str}}aria:courseimage, block_myoverview{{/str}}</span>
+                </div>
             </a>
             <div class="col-sm-8 col-xl-9 span8 align-self-stretch d-flex flex-column">
                 <div class="d-flex">
index 05ecc00..7b3fd69 100644 (file)
@@ -26,25 +26,25 @@ $string['anycategory'] = 'Any category';
 $string['apierror'] = 'The YouTube API key is not set. Contact your administrator.';
 $string['apikey'] = 'API key';
 $string['apikeyinfo'] = 'Get a <a href="https://developers.google.com/youtube/v3/getting-started">Google API key</a> for your Moodle site.';
-$string['autosvehicles'] = 'Autos &amp; Vehicles';
+$string['autosvehicles'] = 'Autos & Vehicles';
 $string['category'] = 'Category';
 $string['comedy'] = 'Comedy';
 $string['configtitle'] = 'YouTube block title';
 $string['education'] = 'Education';
 $string['entertainment'] = 'Entertainment';
-$string['filmsanimation'] = 'Films &amp; Animation';
-$string['gadgetsgames'] = 'Gadgets &amp; Games';
-$string['howtodiy'] = 'How-to &amp; DIY';
+$string['filmsanimation'] = 'Films & Animation';
+$string['gadgetsgames'] = 'Gadgets & Games';
+$string['howtodiy'] = 'How-to & DIY';
 $string['includeonlyvideosfromplaylist'] = 'Include only videos from the playlist with id';
 $string['music'] = 'Music';
-$string['newspolitics'] = 'News &amp; Politics';
+$string['newspolitics'] = 'News & Politics';
 $string['numberofvideos'] = 'Number of videos';
-$string['peopleblogs'] = 'People &amp; Blogs';
-$string['petsanimals'] = 'Pets &amp; Animals';
+$string['peopleblogs'] = 'People & Blogs';
+$string['petsanimals'] = 'Pets & Animals';
 $string['pluginname'] = 'YouTube';
 $string['requesterror'] = 'Data could not be obtained from the server. Contact your administrator if the problem persists.';
-$string['scienceandtech'] = 'Science &amp; Tech';
+$string['scienceandtech'] = 'Science & Tech';
 $string['sports'] = 'Sports';
 $string['tag_youtube:addinstance'] = 'Add a new YouTube block';
-$string['travel'] = 'Travel &amp; Places';
+$string['travel'] = 'Travel & Places';
 $string['privacy:metadata'] = 'The YouTube block only shows data stored in other locations.';
index d474cc9..12dae0d 100644 (file)
@@ -34,7 +34,7 @@ $string['duedate'] = 'Due date';
 $string['morecourses'] = 'More courses';
 $string['timeline:addinstance'] = 'Add a new timeline block';
 $string['timeline:myaddinstance'] = 'Add a new timeline block to Dashboard';
-$string['nocoursesinprogress'] = 'No in progress courses';
+$string['nocoursesinprogress'] = 'No in-progress courses';
 $string['noevents'] = 'No upcoming activities due';
 $string['next30days'] = 'Next 30 days';
 $string['next7days'] = 'Next 7 days';
index d0ca148..84343b9 100644 (file)
@@ -25,7 +25,7 @@
 $string['database'] = 'Database';
 $string['database_help'] = 'The name of the database to make use of.';
 $string['extendedmode'] = 'Use extended keys';
-$string['extendedmode_help'] = 'If enabled full key sets will be used when working with the plugin. This isn\'t used internally yet but would allow you to easily search and investigate the MongoDB plugin manually if you so choose. Turning this on will add an small overhead so should only be done if you require it.';
+$string['extendedmode_help'] = 'If enabled full key sets will be used when working with the plugin. This isn\'t used internally yet but would allow you to easily search and investigate the MongoDB plugin manually if you so choose. Turning this on will add a small overhead so should only be done if you require it.';
 $string['password'] = 'Password';
 $string['password_help'] = 'The password of the user being used for the connection.';
 $string['pleaseupgrademongo'] = 'You are using an old version of the PHP Mongo extension (< 1.3). Support for old versions of the Mongo extension will be dropped in the future. Please consider upgrading.';
index 0873f46..acc1198 100644 (file)
@@ -85,6 +85,7 @@ class core_course_external extends external_api {
     public static function get_course_contents($courseid, $options = array()) {
         global $CFG, $DB;
         require_once($CFG->dirroot . "/course/lib.php");
+        require_once($CFG->libdir . '/completionlib.php');
 
         //validate parameter
         $params = self::validate_parameters(self::get_course_contents_parameters(),
@@ -168,6 +169,8 @@ class core_course_external extends external_api {
             $coursenumsections = course_get_format($course)->get_last_section_number();
             $stealthmodules = array();   // Array to keep all the modules available but not visible in a course section/topic.
 
+            $completioninfo = new completion_info($course);
+
             //for each sections (first displayed to last displayed)
             $modinfosections = $modinfo->get_sections();
             foreach ($sections as $key => $section) {
@@ -261,6 +264,21 @@ class core_course_external extends external_api {
                         $module['modplural'] = $cm->modplural;
                         $module['modicon'] = $cm->get_icon_url()->out(false);
                         $module['indent'] = $cm->indent;
+                        $module['onclick'] = $cm->onclick;
+                        $module['afterlink'] = $cm->afterlink;
+                        $module['customdata'] = json_encode($cm->customdata);
+                        $module['completion'] = $cm->completion;
+
+                        // Check module completion.
+                        $completion = $completioninfo->is_enabled($cm);
+                        if ($completion != COMPLETION_DISABLED) {
+                            $completiondata = $completioninfo->get_data($cm, true);
+                            $module['completiondata'] = array(
+                                'state'         => $completiondata->completionstate,
+                                'timecompleted' => $completiondata->timemodified,
+                                'overrideby'    => $completiondata->overrideby
+                            );
+                        }
 
                         if (!empty($cm->showdescription) or $cm->modname == 'label') {
                             // We want to use the external format. However from reading get_formatted_content(), $cm->content format is always FORMAT_HTML.
@@ -408,6 +426,21 @@ class core_course_external extends external_api {
                                     'modplural' => new external_value(PARAM_TEXT, 'activity module plural name'),
                                     'availability' => new external_value(PARAM_RAW, 'module availability settings', VALUE_OPTIONAL),
                                     'indent' => new external_value(PARAM_INT, 'number of identation in the site'),
+                                    'onclick' => new external_value(PARAM_RAW, 'Onclick action.', VALUE_OPTIONAL),
+                                    'afterlink' => new external_value(PARAM_RAW, 'After link info to be displayed.',
+                                        VALUE_OPTIONAL),
+                                    'customdata' => new external_value(PARAM_RAW, 'Custom data (JSON encoded).', VALUE_OPTIONAL),
+                                    'completion' => new external_value(PARAM_INT, 'Type of completion tracking:
+                                        0 means none, 1 manual, 2 automatic.', VALUE_OPTIONAL),
+                                    'completiondata' => new external_single_structure(
+                                        array(
+                                            'state' => new external_value(PARAM_INT, 'Completion state value:
+                                                0 means incomplete, 1 complete, 2 complete pass, 3 complete fail'),
+                                            'timecompleted' => new external_value(PARAM_INT, 'Timestamp for completion status.'),
+                                            'overrideby' => new external_value(PARAM_INT, 'The user id who has overriden the
+                                                status.'),
+                                        ), 'Module completion data.', VALUE_OPTIONAL
+                                    ),
                                     'contents' => new external_multiple_structure(
                                           new external_single_structure(
                                               array(
index e7759b0..fe759d3 100644 (file)
@@ -809,15 +809,24 @@ class core_course_externallib_testcase extends externallib_advanced_testcase {
         global $DB, $CFG;
 
         $CFG->allowstealth = 1; // Allow stealth activities.
+        $CFG->enablecompletion = true;
+        $CFG->forum_allowforcedreadtracking = 1;
+        $course  = self::getDataGenerator()->create_course(['numsections' => 4, 'enablecompletion' => 1]);
 
-        $course  = self::getDataGenerator()->create_course(['numsections' => 4]);
         $forumdescription = 'This is the forum description';
         $forum = $this->getDataGenerator()->create_module('forum',
-            array('course' => $course->id, 'intro' => $forumdescription),
-            array('showdescription' => true));
+            array('course' => $course->id, 'intro' => $forumdescription, 'trackingtype' => 2),
+            array('showdescription' => true, 'completion' => COMPLETION_TRACKING_MANUAL));
         $forumcm = get_coursemodule_from_id('forum', $forum->cmid);
-        $data = $this->getDataGenerator()->create_module('data', array('assessed' => 1, 'scale' => 100, 'course' => $course->id));
-        $datacm = get_coursemodule_from_instance('page', $data->id);
+        // Add discussions to the tracking forced forum.
+        $record = new stdClass();
+        $record->course = $course->id;
+        $record->userid = 0;
+        $record->forum = $forum->id;
+        $discussionforce = $this->getDataGenerator()->get_plugin_generator('mod_forum')->create_discussion($record);
+        $data = $this->getDataGenerator()->create_module('data',
+            array('assessed' => 1, 'scale' => 100, 'course' => $course->id, 'completion' => 2, 'completionentries' => 3));
+        $datacm = get_coursemodule_from_instance('data', $data->id);
         $page = $this->getDataGenerator()->create_module('page', array('course' => $course->id));
         $pagecm = get_coursemodule_from_instance('page', $page->id);
         // This is an stealth page (set by visibleoncoursepage).
@@ -830,7 +839,8 @@ class core_course_externallib_testcase extends externallib_advanced_testcase {
         $tomorrow = time() + DAYSECS;
         // Module with availability restrictions not met.
         $url = $this->getDataGenerator()->create_module('url',
-            array('course' => $course->id, 'name' => 'URL: % & $ ../', 'section' => 2),
+            array('course' => $course->id, 'name' => 'URL: % & $ ../', 'section' => 2, 'display' => RESOURCELIB_DISPLAY_POPUP,
+                'popupwidth' => 100, 'popupheight' => 100),
             array('availability' => '{"op":"&","c":[{"type":"date","d":">=","t":' . $tomorrow . '}],"showc":[true]}'));
         $urlcm = get_coursemodule_from_instance('url', $url->id);
         // Module for the last section.
@@ -891,7 +901,8 @@ class core_course_externallib_testcase extends externallib_advanced_testcase {
                     array('noclean' => true, 'para' => false, 'filter' => false));
                 $this->assertEquals($formattedtext, $module['description']);
                 $this->assertEquals($forumcm->instance, $module['instance']);
-                $testexecuted = $testexecuted + 1;
+                $this->assertContains('1 unread post', $module['afterlink']);
+                $testexecuted = $testexecuted + 2;
             } else if ($module['id'] == $labelcm->id and $module['modname'] == 'label') {
                 $cm = $modinfo->cms[$labelcm->id];
                 $formattedtext = format_text($cm->content, FORMAT_HTML,
@@ -899,9 +910,19 @@ class core_course_externallib_testcase extends externallib_advanced_testcase {
                 $this->assertEquals($formattedtext, $module['description']);
                 $this->assertEquals($labelcm->instance, $module['instance']);
                 $testexecuted = $testexecuted + 1;
+            } else if ($module['id'] == $datacm->id and $module['modname'] == 'data') {
+                $this->assertContains('customcompletionrules', $module['customdata']);
+                $testexecuted = $testexecuted + 1;
+            }
+        }
+        foreach ($sections[2]['modules'] as $module) {
+            if ($module['id'] == $urlcm->id and $module['modname'] == 'url') {
+                $this->assertContains('width=100,height=100', $module['onclick']);
+                $testexecuted = $testexecuted + 1;
             }
         }
-        $this->assertEquals(2, $testexecuted);
+
+        $this->assertEquals(5, $testexecuted);
         $this->assertEquals(0, $sections[0]['section']);
 
         $this->assertCount(5, $sections[0]['modules']);
@@ -1119,6 +1140,50 @@ class core_course_externallib_testcase extends externallib_advanced_testcase {
         $this->assertEquals($pagecm->instance, $sections[0]['modules'][0]["instance"]);
     }
 
+    /**
+     * Test get course contents completion
+     */
+    public function test_get_course_contents_completion() {
+        global $CFG;
+        $this->resetAfterTest(true);
+
+        list($course, $forumcm, $datacm, $pagecm, $labelcm, $urlcm) = $this->prepare_get_course_contents_test();
+
+        // Test activity not completed yet.
+        $result = core_course_external::get_course_contents($course->id, array(
+            array("name" => "modname", "value" => "forum"), array("name" => "modid", "value" => $forumcm->instance)));
+        // We need to execute the return values cleaning process to simulate the web service server.
+        $result = external_api::clean_returnvalue(core_course_external::get_course_contents_returns(), $result);
+
+        $this->assertCount(1, $result[0]['modules']);
+        $this->assertEquals("forum", $result[0]['modules'][0]["modname"]);
+        $this->assertEquals(COMPLETION_TRACKING_MANUAL, $result[0]['modules'][0]["completion"]);
+        $this->assertEquals(0, $result[0]['modules'][0]["completiondata"]['state']);
+        $this->assertEquals(0, $result[0]['modules'][0]["completiondata"]['timecompleted']);
+        $this->assertEmpty($result[0]['modules'][0]["completiondata"]['overrideby']);
+
+        // Set activity completed.
+        core_completion_external::update_activity_completion_status_manually($forumcm->id, true);
+
+        $result = core_course_external::get_course_contents($course->id, array(
+            array("name" => "modname", "value" => "forum"), array("name" => "modid", "value" => $forumcm->instance)));
+        // We need to execute the return values cleaning process to simulate the web service server.
+        $result = external_api::clean_returnvalue(core_course_external::get_course_contents_returns(), $result);
+
+        $this->assertEquals(COMPLETION_COMPLETE, $result[0]['modules'][0]["completiondata"]['state']);
+        $this->assertNotEmpty($result[0]['modules'][0]["completiondata"]['timecompleted']);
+        $this->assertEmpty($result[0]['modules'][0]["completiondata"]['overrideby']);
+
+        // Disable completion.
+        $CFG->enablecompletion = 0;
+        $result = core_course_external::get_course_contents($course->id, array(
+            array("name" => "modname", "value" => "forum"), array("name" => "modid", "value" => $forumcm->instance)));
+        // We need to execute the return values cleaning process to simulate the web service server.
+        $result = external_api::clean_returnvalue(core_course_external::get_course_contents_returns(), $result);
+
+        $this->assertArrayNotHasKey('completiondata', $result[0]['modules'][0]);
+    }
+
     /**
      * Test duplicate_course
      */
index d952170..c46cfaf 100644 (file)
@@ -3,8 +3,14 @@ information provided here is intended especially for developers.
 
 === 3.6 ===
 
 * External function core_course_external::get_course_public_information now returns the roles and the primary role of course
+ * External function core_course_external::get_course_public_information now returns the roles and the primary role of course
    contacts.
+ * External function core_course_external::get_course_contents now return the following additional file fields:
+   - onclick (onclick javascript action code)
+   - afterlink (after link info to be displayed)
+   - customdata (module custom data (JSON encoded))
+   - completion (to indicate if completion is enabled or not)
+   - completiondata (completion status for the current user in the module)
 
 === 3.5 ===
 
index a1e93c3..f1c3cdb 100644 (file)
@@ -75,7 +75,7 @@ $string['settingshortname'] = 'IMS description tag for the course short name';
 $string['settingshortnamedescription'] = 'The short name is a required course field so you have to define the selected description tag in your IMS Enterprise file';
 $string['settingsummary'] = 'IMS description tag for the course summary';
 $string['settingsummarydescription'] = 'Is an optional field, select \'Leave it empty\' if you dont\'t want to specify a course summary';
-$string['sourcedidfallback'] = 'Use the &quot;sourcedid&quot; for a person\'s userid if the &quot;userid&quot; field is not found';
+$string['sourcedidfallback'] = 'Use the \'sourcedid\' for a user\'s userid if the \'userid\' field is not found';
 $string['sourcedidfallback_desc'] = 'In IMS data, the <sourcedid> field represents the persistent ID code for a person as used in the source system. The <userid> field is a separate field which should contain the ID code used by the user when logging in. In many cases these two codes may be the same - but not always.
 
 Some student information systems fail to output the <userid> field. If this is the case, you should enable this setting to allow for using the <sourcedid> as the Moodle user ID. Otherwise, leave this setting disabled.';
@@ -85,7 +85,7 @@ $string['updatecourses'] = 'Update course';
 $string['updatecourses_desc'] = 'If enabled, the IMS Enterprise enrolment plugin can update course full and short names (if the "recstatus" flag is set to 2, which represents an update).';
 $string['updateusers'] = 'Update user accounts when specified in IMS data';
 $string['updateusers_desc'] = 'If enabled, IMS Enterprise enrolment data can specify changes to user accounts (if the "recstatus" flag is set to 2, which represents an update).';
-$string['usecapitafix'] = 'Tick this box if using &quot;Capita&quot; (their XML format is slightly wrong)';
+$string['usecapitafix'] = 'Tick this box if using Capita (as their XML format is slightly different)';
 $string['usecapitafix_desc'] = 'The student data system produced by Capita has been found to have one slight error in its XML output. If you are using Capita you should enable this setting - otherwise leave it un-ticked.';
 $string['usersettings'] = 'User data options';
 $string['zeroisnotruncation'] = '0 indicates no truncation';
index e74ef64..dd109aa 100644 (file)
@@ -1,6 +1,12 @@
 This files describes API changes in /grade/report/*,
 information provided here is intended especially for developers.
 
+=== 3.6 ===
+* External function gradereport_user_external::get_grade_items now return the following information (only for course managers).
+  - locked: Whether the grade item is locked.
+  - gradeislocked: Whether the user grade is locked.
+  - gradeisoverridden: Whether the user grade is overridden.
+
 === 3.2 ===
 * External function gradereport_user_external::get_grades_table now has an optional groupid parameter.
 Is recommended to use this parameter in courses with separate groups or when the user requesting the report is in more than one group.
index 5d7bbb6..8bbd6e5 100644 (file)
@@ -491,6 +491,7 @@ class gradereport_user_external extends external_api {
                                         'categoryid' => new external_value(PARAM_INT, 'Grade item category id'),
                                         'outcomeid' => new external_value(PARAM_INT, 'Outcome id'),
                                         'scaleid' => new external_value(PARAM_INT, 'Scale id'),
+                                        'locked' => new external_value(PARAM_BOOL, 'Grade item for user locked?', VALUE_OPTIONAL),
                                         'cmid' => new external_value(PARAM_INT, 'Course module id (if type mod)', VALUE_OPTIONAL),
                                         'weightraw' => new external_value(PARAM_FLOAT, 'Weight raw', VALUE_OPTIONAL),
                                         'weightformatted' => new external_value(PARAM_NOTAGS, 'Weight', VALUE_OPTIONAL),
@@ -501,6 +502,8 @@ class gradereport_user_external extends external_api {
                                         'gradehiddenbydate' => new external_value(PARAM_BOOL, 'Grade hidden by date?', VALUE_OPTIONAL),
                                         'gradeneedsupdate' => new external_value(PARAM_BOOL, 'Grade needs update?', VALUE_OPTIONAL),
                                         'gradeishidden' => new external_value(PARAM_BOOL, 'Grade is hidden?', VALUE_OPTIONAL),
+                                        'gradeislocked' => new external_value(PARAM_BOOL, 'Grade is locked?', VALUE_OPTIONAL),
+                                        'gradeisoverridden' => new external_value(PARAM_BOOL, 'Grade overridden?', VALUE_OPTIONAL),
                                         'gradeformatted' => new external_value(PARAM_NOTAGS, 'The grade formatted', VALUE_OPTIONAL),
                                         'grademin' => new external_value(PARAM_FLOAT, 'Grade min', VALUE_OPTIONAL),
                                         'grademax' => new external_value(PARAM_FLOAT, 'Grade max', VALUE_OPTIONAL),
index dab9bb5..7290b18 100644 (file)
@@ -491,7 +491,7 @@ class grade_report_user extends grade_report {
                     $excluded = ' excluded';
                 }
                 **/
-
+                $canviewall = has_capability('moodle/grade:viewall', $this->context);
                 /// Other class information
                 $class .= $hidden . $excluded;
                 if ($this->switch) { // alter style based on whether aggregation is first or last
@@ -520,6 +520,7 @@ class grade_report_user extends grade_report {
                 $gradeitemdata['categoryid'] = $grade_object->categoryid;
                 $gradeitemdata['outcomeid'] = $grade_object->outcomeid;
                 $gradeitemdata['scaleid'] = $grade_object->outcomeid;
+                $gradeitemdata['locked'] = $canviewall ? $grade_grade->grade_item->is_locked() : null;
 
                 if ($this->showfeedback) {
                     // Copy $class before appending itemcenter as feedback should not be centered
@@ -551,6 +552,8 @@ class grade_report_user extends grade_report {
                     $gradeitemdata['gradeishidden'] = $grade_grade->is_hidden();
                     $gradeitemdata['gradedatesubmitted'] = $grade_grade->get_datesubmitted();
                     $gradeitemdata['gradedategraded'] = $grade_grade->get_dategraded();
+                    $gradeitemdata['gradeislocked'] = $canviewall ? $grade_grade->is_locked() : null;
+                    $gradeitemdata['gradeisoverridden'] = $canviewall ? $grade_grade->is_overridden() : null;
 
                     if ($grade_grade->grade_item->needsupdate) {
                         $data['grade']['class'] = $class.' gradingerror';
index e751434..6057a38 100644 (file)
@@ -255,6 +255,7 @@ class gradereport_user_externallib_testcase extends externallib_advanced_testcas
         $this->assertEquals('mod', $studentgrades['usergrades'][0]['gradeitems'][0]['itemtype']);
         $this->assertEquals('assign', $studentgrades['usergrades'][0]['gradeitems'][0]['itemmodule']);
         $this->assertEquals($assignment->id, $studentgrades['usergrades'][0]['gradeitems'][0]['iteminstance']);
+        $this->assertFalse($studentgrades['usergrades'][0]['gradeitems'][0]['locked']);
         $this->assertEquals($assignment->cmidnumber, $studentgrades['usergrades'][0]['gradeitems'][0]['cmid']);
         $this->assertEquals(0, $studentgrades['usergrades'][0]['gradeitems'][0]['itemnumber']);
         $this->assertEmpty($studentgrades['usergrades'][0]['gradeitems'][0]['outcomeid']);
@@ -269,6 +270,8 @@ class gradereport_user_externallib_testcase extends externallib_advanced_testcas
         $this->assertFalse($studentgrades['usergrades'][0]['gradeitems'][0]['gradehiddenbydate']);
         $this->assertFalse($studentgrades['usergrades'][0]['gradeitems'][0]['gradeneedsupdate']);
         $this->assertFalse($studentgrades['usergrades'][0]['gradeitems'][0]['gradeishidden']);
+        $this->assertFalse($studentgrades['usergrades'][0]['gradeitems'][0]['gradeislocked']);
+        $this->assertFalse($studentgrades['usergrades'][0]['gradeitems'][0]['gradeisoverridden']);
         $this->assertEquals('B-', $studentgrades['usergrades'][0]['gradeitems'][0]['lettergradeformatted']);
         $this->assertEquals(1, $studentgrades['usergrades'][0]['gradeitems'][0]['rank']);
         $this->assertEquals(2, $studentgrades['usergrades'][0]['gradeitems'][0]['numusers']);
@@ -280,16 +283,34 @@ class gradereport_user_externallib_testcase extends externallib_advanced_testcas
         $this->assertEquals('80.00', $studentgrades['usergrades'][0]['gradeitems'][1]['gradeformatted']);
         $this->assertEquals(0, $studentgrades['usergrades'][0]['gradeitems'][1]['grademin']);
         $this->assertEquals(100, $studentgrades['usergrades'][0]['gradeitems'][1]['grademax']);
+        $this->assertFalse($studentgrades['usergrades'][0]['gradeitems'][1]['locked']);
         $this->assertEquals('0&ndash;100', $studentgrades['usergrades'][0]['gradeitems'][1]['rangeformatted']);
         $this->assertEquals('80.00 %', $studentgrades['usergrades'][0]['gradeitems'][1]['percentageformatted']);
         $this->assertEmpty($studentgrades['usergrades'][0]['gradeitems'][1]['feedback']);
         $this->assertFalse($studentgrades['usergrades'][0]['gradeitems'][1]['gradehiddenbydate']);
         $this->assertFalse($studentgrades['usergrades'][0]['gradeitems'][1]['gradeneedsupdate']);
         $this->assertFalse($studentgrades['usergrades'][0]['gradeitems'][1]['gradeishidden']);
+        $this->assertFalse($studentgrades['usergrades'][0]['gradeitems'][1]['gradeislocked']);
+        $this->assertFalse($studentgrades['usergrades'][0]['gradeitems'][1]['gradeisoverridden']);
         $this->assertEquals('B-', $studentgrades['usergrades'][0]['gradeitems'][1]['lettergradeformatted']);
         $this->assertEquals(1, $studentgrades['usergrades'][0]['gradeitems'][1]['rank']);
         $this->assertEquals(2, $studentgrades['usergrades'][0]['gradeitems'][1]['numusers']);
         $this->assertEquals(70, $studentgrades['usergrades'][0]['gradeitems'][1]['averageformatted']);
+
+        // Now, override and lock a grade.
+        $gradegrade = grade_grade::fetch(['itemid' => $studentgrades['usergrades'][0]['gradeitems'][0]['id'],
+            'userid' => $studentgrades['usergrades'][0]['userid']]);
+        $gradegrade->set_overridden(true);
+        $gradegrade->set_locked(1);
+
+        $studentgrades = gradereport_user_external::get_grade_items($course->id);
+        $studentgrades = external_api::clean_returnvalue(gradereport_user_external::get_grade_items_returns(), $studentgrades);
+        // No warnings returned.
+        $this->assertCount(0, $studentgrades['warnings']);
+
+        // Module grades.
+        $this->assertTrue($studentgrades['usergrades'][0]['gradeitems'][0]['gradeislocked']);
+        $this->assertTrue($studentgrades['usergrades'][0]['gradeitems'][0]['gradeisoverridden']);
     }
 
     /**
@@ -330,6 +351,7 @@ class gradereport_user_externallib_testcase extends externallib_advanced_testcas
         $this->assertEquals('mod', $studentgrades['usergrades'][0]['gradeitems'][0]['itemtype']);
         $this->assertEquals('assign', $studentgrades['usergrades'][0]['gradeitems'][0]['itemmodule']);
         $this->assertEquals($assignment->id, $studentgrades['usergrades'][0]['gradeitems'][0]['iteminstance']);
+        $this->assertNull($studentgrades['usergrades'][0]['gradeitems'][0]['locked']);
         $this->assertEquals($assignment->cmidnumber, $studentgrades['usergrades'][0]['gradeitems'][0]['cmid']);
         $this->assertEquals(0, $studentgrades['usergrades'][0]['gradeitems'][0]['itemnumber']);
         $this->assertEmpty($studentgrades['usergrades'][0]['gradeitems'][0]['outcomeid']);
@@ -344,6 +366,8 @@ class gradereport_user_externallib_testcase extends externallib_advanced_testcas
         $this->assertFalse($studentgrades['usergrades'][0]['gradeitems'][0]['gradehiddenbydate']);
         $this->assertFalse($studentgrades['usergrades'][0]['gradeitems'][0]['gradeneedsupdate']);
         $this->assertFalse($studentgrades['usergrades'][0]['gradeitems'][0]['gradeishidden']);
+        $this->assertNull($studentgrades['usergrades'][0]['gradeitems'][0]['gradeislocked']);
+        $this->assertNull($studentgrades['usergrades'][0]['gradeitems'][0]['gradeisoverridden']);
         $this->assertEquals('B-', $studentgrades['usergrades'][0]['gradeitems'][0]['lettergradeformatted']);
         $this->assertEquals(1, $studentgrades['usergrades'][0]['gradeitems'][0]['rank']);
         $this->assertEquals(2, $studentgrades['usergrades'][0]['gradeitems'][0]['numusers']);
index 2bc3ca7..11d79f3 100644 (file)
@@ -280,7 +280,7 @@ $string['configmaxevents'] = 'Events to Lookahead';
 $string['configmessaging'] = 'If enabled, users can send messages to other users on the site.';
 $string['configmessagingallowemailoverride'] = 'Allow users to have email message notifications sent to an email address other than the email address in their profile';
 $string['configmessagingdeletereadnotificationsdelay'] = 'Read notifications can be deleted to save space. How long after a notification is read can it be deleted?';
-$string['configmessagingallusers'] = 'If enabled, users can choose to allow anyone on the site to send them a message. Otherwise, users can choose to allow only their contacts or others in their courses to send them messages.';
+$string['configmessagingallusers'] = 'If enabled, users can view the list of all users on the site when selecting someone to message, and their message preferences include the option to accept messages from anyone on the site. If disabled, users can only view the list of users in their courses, and they have just two options in message preferences - to accept messages from their contacts only, or their contacts and anyone in their courses.';
 $string['configminpassworddigits'] = 'Passwords must have at least these many digits.';
 $string['configminpasswordlength'] = 'Passwords must be at least these many characters long.';
 $string['configminpasswordlower'] = 'Passwords must have at least these many lower case letters.';
@@ -757,7 +757,7 @@ $string['mediapluginwmv'] = 'Enable .wmv filter';
 $string['mediapluginyoutube'] = 'Enable YouTube links filter';
 $string['messaging'] = 'Enable messaging system';
 $string['messagingallowemailoverride'] = 'Notification email override';
-$string['messagingallusers'] = 'Allow messages from anyone on the site';
+$string['messagingallusers'] = 'Allow site-wide messaging';
 $string['messagingdeletereadnotificationsdelay'] = 'Delete read notifications';
 $string['minpassworddigits'] = 'Digits';
 $string['minpasswordlength'] = 'Password length';
@@ -1266,7 +1266,7 @@ $string['upgradekeyset'] = 'Upgrade key (leave empty to not set it)';
 $string['upgradelogs'] = 'For full functionality, your old logs need to be upgraded.  <a href="{$a}">More information</a>';
 $string['upgradelogsinfo'] = 'Some changes have recently been made in the way logs are stored.  To be able to view all of your old logs on a per-activity basis, your old logs need to be upgraded.  Depending on your site this can take a long time (eg several hours) and can be quite taxing on the database for large sites.  Once you start this process you should let it finish (by keeping the browser window open).  Don\'t worry - your site will work fine for other people while the logs are being upgraded.<br /><br />Do you want to upgrade your logs now?';
 $string['upgradesettings'] = 'New settings';
-$string['upgradesettingsintro'] = 'The settings shown below were added during your last Moodle upgrade. Make any changes necessary to the defaults and then click the &quot;Save changes&quot; button at the bottom of this page.';
+$string['upgradesettingsintro'] = 'The settings shown below were added during your last Moodle upgrade. Make any changes necessary to the defaults and then click the \'Save changes\' button at the bottom of this page.';
 $string['upgradestalefiles'] = 'Mixed Moodle versions detected, upgrade cannot continue';
 $string['upgradestalefilesinfo'] = 'The Moodle update process has been paused because PHP scripts from at least two major versions of Moodle have been detected in the Moodle directory.
 
index 9d4715e..75e6adf 100644 (file)
@@ -31,6 +31,8 @@ $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['defaultpredictionsprocessor'] = 'Default predictions processor';
+$string['defaultpredictoroption'] = 'Default processor ({$a})';
 $string['disabledmodel'] = 'Disabled model';
 $string['erroralreadypredict'] = 'File {$a} has already been used to generate predictions.';
 $string['errorcannotreaddataset'] = 'Dataset file {$a} can not be read';
@@ -82,7 +84,7 @@ $string['novalidsamples'] = 'No valid samples available';
 $string['onlycli'] = 'Analytics processes execution via command line only';
 $string['onlycliinfo'] = 'Analytics processes like evaluating models, training machine learning algorithms or getting predictions can take some time, they will run as cron tasks and they can be forced via command line. Disable this setting if you want your site managers to be able to run these processes manually via web interface';
 $string['predictionsprocessor'] = 'Predictions processor';
-$string['predictionsprocessor_help'] = 'A predictions processor is the machine-learning backend that processes the datasets generated by calculating models\' indicators and targets. All trained algorithms and predictions will be deleted if you change to another predictions processor.';
+$string['predictionsprocessor_help'] = 'A predictions processor is the machine-learning backend that processes the datasets generated by calculating models\' indicators and targets. Each model can use a different processor, the one specified here will be the default value.';
 $string['privacy:metadata:analytics:indicatorcalc'] = 'Indicator calculations';
 $string['privacy:metadata:analytics:indicatorcalc:starttime'] = 'Calculation start time';
 $string['privacy:metadata:analytics:indicatorcalc:endtime'] = 'Calculation end time';
index bfd0c89..86d813c 100644 (file)
@@ -33,7 +33,7 @@ $string['codingerror'] = 'Coding error detected, it must be fixed by a programme
 $string['configmoodle'] = 'Moodle has not been configured yet. You need to edit config.php first.';
 $string['erroroccur'] = 'An error has occurred during this process';
 $string['invalidarraysize'] = 'Incorrect size of arrays in params of {$a}';
-$string['invalideventdata'] = 'Incorrect eventadata submitted: {$a}';
+$string['invalideventdata'] = 'Incorrect event data submitted: {$a}';
 $string['invalidparameter'] = 'Invalid parameter value detected';
 $string['invalidresponse'] = 'Invalid response value detected';
 $string['missingconfigversion'] = 'Config table does not contain version, can not continue, sorry.';
index 3cffa8f..cabf387 100644 (file)
@@ -124,7 +124,7 @@ $string['groupnotamember'] = 'Sorry, you are not a member of that group';
 $string['groups'] = 'Groups';
 $string['groupscount'] = 'Groups ({$a})';
 $string['groupsettingsheader'] = 'Groups';
-$string['groupsgroupings'] = 'Groups &amp; groupings';
+$string['groupsgroupings'] = 'Groups & groupings';
 $string['groupsinselectedgrouping'] = 'Groups in:';
 $string['groupsnone'] = 'No groups';
 $string['groupsonly'] = 'Groups only';
index 3816a54..98a021a 100644 (file)
@@ -48,7 +48,7 @@ $string['cliinstallfinished'] = 'Installation completed successfully.';
 $string['cliinstallheader'] = 'Moodle {$a} command line installation program';
 $string['climustagreelicense'] = 'In non interactive mode you must agree to license by specifying --agree-license option';
 $string['cliskipdatabase'] = 'Skipping database installation.';
-$string['clitablesexist'] = 'Database tables already present, cli installation can not continue.';
+$string['clitablesexist'] = 'Database tables already present; CLI installation cannot continue.';
 $string['compatibilitysettings'] = 'Checking your PHP settings ...';
 $string['compatibilitysettingshead'] = 'Checking your PHP settings ...';
 $string['compatibilitysettingssub'] = 'Your server should pass all these tests to make Moodle run properly';
index fef0a72..485cf39 100644 (file)
@@ -199,5 +199,5 @@ $string['you'] = 'You:';
 $string['eventmessagecontactblocked'] = 'Message contact blocked';
 $string['eventmessagecontactunblocked'] = 'Message contact unblocked';
 $string['messagingdisabled'] = 'Messaging is disabled on this site, emails will be sent instead';
-$string['userisblockingyou'] = 'This user has blocked you from sending messages to them';
+$string['userisblockingyou'] = 'This user has blocked you from sending messages to them.';
 $string['userisblockingyounoncontact'] = '{$a} only accepts messages from their contacts.';
\ No newline at end of file
index f7e08d6..2a4bb15 100644 (file)
@@ -266,19 +266,19 @@ $string['confirmed'] = 'Your registration has been confirmed';
 $string['confirmednot'] = 'Your registration has not yet been confirmed. Please check your mailbox for a confirmation email.';
 $string['confirmcheckfull'] = 'Are you absolutely sure you want to confirm {$a} ?';
 $string['confirmcoursemove'] = 'Are you sure you want to move this course ({$a->course}) into this category ({$a->category})?';
-$string['considereddigitalminor'] = 'You are considered to be a digital minor.';
+$string['considereddigitalminor'] = 'You are too young to create an account on this site.';
 $string['content'] = 'Content';
 $string['continue'] = 'Continue';
 $string['continuetocourse'] = 'Click here to enter your course';
 $string['convertingwikitomarkdown'] = 'Converting wiki to Markdown';
 $string['cookiesenabled'] = 'Cookies must be enabled in your browser';
-$string['cookiesenabled_help'] = 'Two cookies are used by this site:
+$string['cookiesenabled_help'] = 'Two cookies are used on this site:
 
-The essential one is the session cookie, usually called MoodleSession. You must allow this cookie into your browser to provide continuity and maintain your login from page to page. When you log out or close the browser this cookie is destroyed (in your browser and on the server).
+The essential one is the session cookie, usually called MoodleSession. You must allow this cookie in your browser to provide continuity and to remain logged in when browsing the site. When you log out or close the browser, this cookie is destroyed (in your browser and on the server).
 
-The other cookie is purely for convenience, usually called something like MOODLEID. It just remembers your username within the browser. This means when you return to this site the username field on the login page will be already filled out for you. It is safe to refuse this cookie - you will just have to retype your username every time you log in.';
+The other cookie is purely for convenience, usually called MOODLEID or similar. It just remembers your username in the browser. This means that when you return to this site, the username field on the login page is already filled in for you. It is safe to refuse this cookie - you will just have to retype your username each time you log in.';
 $string['cookiesenabledonlysession'] = 'Cookies must be enabled in your browser';
-$string['cookiesenabledonlysession_help'] = 'This site uses one session cookie, usually called MoodleSession. You must allow this cookie into your browser to provide continuity and maintain your login from page to page. When you log out or close the browser this cookie is destroyed (in your browser and on the server).';
+$string['cookiesenabledonlysession_help'] = 'This site uses one session cookie, usually called MoodleSession. You must allow this cookie in your browser to provide continuity and to remain logged in when browsing the site. When you log out or close the browser, this cookie is destroyed (in your browser and on the server).';
 $string['cookiesnotenabled'] = 'Unfortunately, cookies are currently not enabled in your browser';
 $string['copy'] = 'copy';
 $string['copyasnoun'] = 'copy';
@@ -327,6 +327,7 @@ $string['courseformatdata'] = 'Course format data';
 $string['courseformats'] = 'Course formats';
 $string['courseformatoptions'] = 'Formatting options for {$a}';
 $string['courseformatudpate'] = 'Update format';
+$string['courseheaderimage'] = 'Course header image';
 $string['courseprofiles'] = 'Course profiles';
 $string['coursepreferences'] = 'Course preferences';
 $string['coursegrades'] = 'Course grades';
@@ -506,7 +507,7 @@ $string['deselectall'] = 'Deselect all';
 $string['detailedless'] = 'Less detailed';
 $string['detailedmore'] = 'More detailed';
 $string['digitalminor'] = 'Digital minor';
-$string['digitalminor_desc'] = 'To create an account on this site please have your parent/guardian contact the following person.';
+$string['digitalminor_desc'] = 'Please ask your parent/guardian to contact:';
 $string['directory'] = 'Directory';
 $string['disable'] = 'Disable';
 $string['disabledcomments'] = 'Comments are disabled';
@@ -1383,7 +1384,7 @@ $string['nocourseendtime'] = 'The course does not have an end time';
 $string['nocourses'] = 'No courses';
 $string['nocoursesections'] = 'No course sections';
 $string['nocoursesfound'] = 'No courses were found with the words \'{$a}\'';
-$string['nocoursestarttime'] = 'The course does not have an start time';
+$string['nocoursestarttime'] = 'The course does not have a start date.';
 $string['nocoursestudents'] = 'No students';
 $string['nocoursesyet'] = 'No courses in this category';
 $string['nocomments'] = 'No comments';
@@ -1518,7 +1519,7 @@ $string['payments'] = 'Payments';
 $string['paymentsorry'] = 'Thank you for your payment!  Unfortunately your payment has not yet been fully processed, and you are not yet registered to enter the course "{$a->fullname}".  Please try continuing to the course in a few seconds, but if you continue to have trouble then please alert the {$a->teacher} or the site administrator';
 $string['paymentthanks'] = 'Thank you for your payment!  You are now enrolled in your course:<br />"{$a}"';
 $string['pendingrequests'] = 'Pending requests';
-$string['percents'] = '{$a} %';
+$string['percents'] = '{$a}%';
 $string['periodending'] = 'Period ending ({$a})';
 $string['perpage'] = 'Per page';
 $string['perpagea'] = 'Per page: {$a}';
@@ -1822,6 +1823,8 @@ $string['showallusers'] = 'Show all users';
 $string['showblockcourse'] = 'Show list of courses containing block';
 $string['showcategory'] = 'Show {$a}';
 $string['showchartdata'] = 'Show chart data';
+$string['showcourseimages'] = 'Show course images';
+$string['showcourseimages_desc'] = 'Show the course image or image placeholder in the course header.';
 $string['showcomments'] = 'Show/hide comments';
 $string['showcommentsnonjs'] = 'Show comments';
 $string['showdescription'] = 'Display description on course page';
@@ -2162,8 +2165,7 @@ $string['withdisablednote'] = '{$a} (disabled)';
 $string['withoutuserdata'] = 'without user data';
 $string['withselectedusers'] = 'With selected users...';
 $string['withselectedusers_help'] = '* Send message - For sending a message to one or more participants
-* Add a new note - For adding a note to a selected participant
-* Add a common note - For adding the same note to more than one participant';
+* Add a new note - For adding a note to a selected participant';
 $string['withuserdata'] = 'with user data';
 $string['wordforstudent'] = 'Your word for Student';
 $string['wordforstudenteg'] = 'eg Student, Participant etc';
index b853063..3f31f78 100644 (file)
@@ -388,7 +388,7 @@ $string['penaltyforeachincorrecttry_help'] = 'When questions are run using the \
 
 The penalty is a proportion of the total question grade, so if the question is worth three marks, and the penalty is 0.3333333, then the student will score 3 if they get the question right first time, 2 if they get it right second try, and 1 of they get it right on the third try.';
 $string['previewquestion'] = 'Preview question: {$a}';
-$string['privacy:metadata:database:question'] = 'The details about an specific question.';
+$string['privacy:metadata:database:question'] = 'The details about a specific question.';
 $string['privacy:metadata:database:question:createdby'] = 'The person who created the question.';
 $string['privacy:metadata:database:question:generalfeedback'] = 'The general feedback for this question.';
 $string['privacy:metadata:database:question:modifiedby'] = 'The person who last updated the question.';
index 6f76a10..98624dc 100644 (file)
@@ -47,8 +47,8 @@ $string['areauserprofile'] = 'Profile';
 $string['attachedfiles'] = 'Attached files';
 $string['attachment'] = 'Attachment';
 $string['author'] = 'Author';
-$string['back'] = '&laquo; Back';
-$string['backtodraftfiles'] = '&laquo; Back to draft files manager';
+$string['back'] = 'Back';
+$string['backtodraftfiles'] = 'Back to draft files manager';
 $string['cachecleared'] = 'Cached files are removed';
 $string['cacheexpire'] = 'Cache expire';
 $string['cannotaccessparentwin'] = 'If parent window is on HTTPS, then we are not allowed to access window.opener object, so we cannot refresh the repository for you automatically, but we already got your session, just go back to file picker and select the repository again, it should work now.';
index e72e036..5692065 100644 (file)
@@ -107,7 +107,7 @@ $string['privacy:metadata:summary'] = 'A description of the course.';
 $string['privacy:metadata:theme'] = 'A user preference for the theme to display.';
 $string['privacy:metadata:timeaccess'] = 'The time for access to the course.';
 $string['privacy:metadata:timecreated'] = 'The time this record was created.';
-$string['privacy:metadata:timemodified'] = 'The time this records was modified.';
+$string['privacy:metadata:timemodified'] = 'The time when the record was modified';
 $string['privacy:metadata:timererequested'] = 'The time the user re-requested the password reset.';
 $string['privacy:metadata:timerequested'] = 'The time that the user first requested this password reset';
 $string['privacy:metadata:timezone'] = 'The timezone of the user';
index 1e1d023..884b0fe 100644 (file)
@@ -40,7 +40,8 @@ class mlbackend extends base {
      * @return bool
      */
     public function is_uninstall_allowed() {
-        return true;
+
+        return !\core_analytics\manager::is_mlbackend_used('mlbackend_' . $this->name);
     }
 
     /**
index 6835116..b407071 100644 (file)
@@ -1,5 +1,5 @@
 <?xml version="1.0" encoding="UTF-8" ?>
-<XMLDB PATH="lib/db" VERSION="20181018" COMMENT="XMLDB file for core Moodle tables"
+<XMLDB PATH="lib/db" VERSION="20181022" 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="predictionsprocessor" TYPE="char" LENGTH="255" NOTNULL="false" SEQUENCE="false"/>
         <FIELD NAME="version" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="false"/>
         <FIELD NAME="timecreated" TYPE="int" LENGTH="10" NOTNULL="false" SEQUENCE="false"/>
         <FIELD NAME="timemodified" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="false"/>
       </INDEXES>
     </TABLE>
   </TABLES>
-</XMLDB>
\ No newline at end of file
+</XMLDB>
index f9e1185..1c4a37f 100644 (file)
@@ -66,6 +66,14 @@ $functions = array(
         'ajax'          => true,
         'loginrequired' => false,
     ),
+    'core_auth_resend_confirmation_email' => array(
+        'classname'   => 'core_auth_external',
+        'methodname'  => 'resend_confirmation_email',
+        'description' => 'Resend confirmation email.',
+        'type'        => 'write',
+        'ajax'          => true,
+        'loginrequired' => false,
+    ),
     'core_badges_get_user_badges' => array(
         'classname'     => 'core_badges_external',
         'methodname'    => 'get_user_badges',
index 2aa8774..a8bc03c 100644 (file)
@@ -2652,5 +2652,25 @@ function xmldb_main_upgrade($oldversion) {
         upgrade_main_savepoint(true, 2018102300.02);
     }
 
+    if ($oldversion < 2018102900.00) {
+        // Define field predictionsprocessor to be added to analytics_models.
+        $table = new xmldb_table('analytics_models');
+        $field = new xmldb_field('predictionsprocessor', XMLDB_TYPE_CHAR, '255', null, null, null, null, 'timesplitting');
+
+        // Conditionally launch add field predictionsprocessor.
+        if (!$dbman->field_exists($table, $field)) {
+            $dbman->add_field($table, $field);
+        }
+
+        // Main savepoint reached.
+        upgrade_main_savepoint(true, 2018102900.00);
+    }
+
+    if ($oldversion < 2018102900.01) {
+        // Show course images by default.
+        set_config('showcourseimages', 1, 'moodlecourse');
+        upgrade_main_savepoint(true, 2018102900.01);
+    }
+
     return true;
 }
index a1c549d..c3fd597 100644 (file)
@@ -4153,7 +4153,7 @@ EOD;
     }
 
     public function context_header($headerinfo = null, $headinglevel = 1) {
-        global $DB, $USER, $CFG;
+        global $DB, $USER, $CFG, $COURSE;
         require_once($CFG->dirroot . '/user/lib.php');
         $context = $this->page->context;
         $heading = null;
@@ -4164,6 +4164,14 @@ EOD;
         if (isset($headerinfo['heading'])) {
             $heading = $headerinfo['heading'];
         }
+
+        // Show a course image if enabled.
+        if ($context->contextlevel == CONTEXT_COURSE && get_config('moodlecourse', 'showcourseimages')) {
+            $exporter = new core_course\external\course_summary_exporter($COURSE, ['context' => $context]);
+            $courseinfo = $exporter->export($this);
+            $imagedata = $this->render_from_template('core/course_header_image', $courseinfo);
+        }
+
         // The user context currently has images and buttons. Other contexts may follow.
         if (isset($headerinfo['user']) || $context->contextlevel == CONTEXT_USER) {
             if (isset($headerinfo['user'])) {
@@ -4257,6 +4265,11 @@ EOD;
       */
     protected function render_context_header(context_header $contextheader) {
 
+        $showheader = empty($this->page->layout_options['nocontextheader']);
+        if (!$showheader) {
+            return '';
+        }
+
         // All the html stuff goes here.
         $html = html_writer::start_div('page-context-header');
 
diff --git a/lib/templates/course_header_image.mustache b/lib/templates/course_header_image.mustache
new file mode 100644 (file)
index 0000000..52ab679
--- /dev/null
@@ -0,0 +1,29 @@
+{{!
+    This file is part of Moodle - http://moodle.org/
+
+    Moodle is free software: you can redistribute it and/or modify
+    it under the terms of the GNU General Public License as published by
+    the Free Software Foundation, either version 3 of the License, or
+    (at your option) any later version.
+
+    Moodle is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+    GNU General Public License for more details.
+
+    You should have received a copy of the GNU General Public License
+    along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+}}
+{{!
+    @template core/course_header_image
+
+    Example context (json):
+    {
+        "courseimage": "http://domain.name/pluginfile.php/123/course/overviewfiles/kitten.jpg"
+    }
+}}
+<div class="course-header-image-wrapper">
+    <div class="course-header-image rounded w-100 h-100" style='background-image: url("{{{courseimage}}}");'>
+        <div class="sr-only">{{#str}}courseheaderimage, core{{/str}}</div>
+    </div>
+</div>
\ No newline at end of file
index d5722ff..8fa2151 100644 (file)
@@ -37,7 +37,7 @@ $string['ignoremodified_help'] = 'When the grading worksheet is downloaded from
 $string['importgrades'] = 'Confirm changes in grading worksheet';
 $string['invalidgradeimport'] = 'Moodle could not read the uploaded worksheet. Make sure it is saved in comma separated value format (.csv) and try again.';
 $string['gradesfile'] = 'Grading worksheet (csv format)';
-$string['gradesfile_help'] = 'Grading worksheet with modified grades. This file must be a csv file that has been downloaded from this assignment and must contain columns for the student grade, and identifier. The encoding for the file must be &quot;UTF-8&quot;';
+$string['gradesfile_help'] = 'Grading worksheet with modified grades. This file must be a CSV file with UTF-8 encoding that has been downloaded from the assignment, with columns for student grade and identifier.';
 $string['privacy:nullproviderreason'] = 'This plugin has no database to store user information. It only uses APIs in mod_assign to help with displaying the grading interface.';
 $string['nochanges'] = 'No modified grades found in uploaded worksheet';
 $string['offlinegradingworksheet'] = 'Grades';
index 624e4d9..d48d8f5 100644 (file)
@@ -316,7 +316,7 @@ $string['markingworkflowstatereadyforreview'] = 'Marking completed';
 $string['markingworkflowstatereadyforrelease'] = 'Ready for release';
 $string['markingworkflowstatereleased'] = 'Released';
 $string['maxattempts'] = 'Maximum attempts';
-$string['maxattempts_help'] = 'The maximum number of submissions attempts that can be made by a student. After this number of attempts has been made the student&apos;s submission will not be able to be reopened.';
+$string['maxattempts_help'] = 'The maximum number of submission attempts that can be made by a student. After this number has been reached, the submission can no longer be reopened.';
 $string['maxgrade'] = 'Maximum grade';
 $string['maxgrade'] = 'Maximum Grade';
 $string['maxperpage'] = 'Maximum assignments per page';
index 5fa4bc8..353ba0a 100644 (file)
@@ -18,6 +18,7 @@
  * Privacy Subsystem implementation for mod_choice.
  *
  * @package    mod_choice
+ * @category   privacy
  * @copyright  2018 Jun Pataleta
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
@@ -26,9 +27,11 @@ namespace mod_choice\privacy;
 
 use core_privacy\local\metadata\collection;
 use core_privacy\local\request\approved_contextlist;
+use core_privacy\local\request\approved_userlist;
 use core_privacy\local\request\contextlist;
 use core_privacy\local\request\deletion_criteria;
 use core_privacy\local\request\helper;
+use core_privacy\local\request\userlist;
 use core_privacy\local\request\writer;
 
 defined('MOODLE_INTERNAL') || die();
@@ -44,7 +47,10 @@ class provider implements
         \core_privacy\local\metadata\provider,
 
         // This plugin is a core_user_data_provider.
-        \core_privacy\local\request\plugin\provider {
+        \core_privacy\local\request\plugin\provider,
+
+        // This plugin is capable of determining which users have data within it.
+        \core_privacy\local\request\core_userlist_provider {
     /**
      * Return the fields which contain personal data.
      *
@@ -94,6 +100,35 @@ class provider implements
         return $contextlist;
     }
 
+    /**
+     * Get the list of users who have data within a context.
+     *
+     * @param   userlist    $userlist   The userlist containing the list of users who have data in this context/plugin combination.
+     */
+    public static function get_users_in_context(userlist $userlist) {
+        $context = $userlist->get_context();
+
+        if (!$context instanceof \context_module) {
+            return;
+        }
+
+        // Fetch all choice answers.
+        $sql = "SELECT ca.userid
+                  FROM {course_modules} cm
+                  JOIN {modules} m ON m.id = cm.module AND m.name = :modname
+                  JOIN {choice} ch ON ch.id = cm.instance
+                  JOIN {choice_options} co ON co.choiceid = ch.id
+                  JOIN {choice_answers} ca ON ca.optionid = co.id AND ca.choiceid = ch.id
+                 WHERE cm.id = :cmid";
+
+        $params = [
+            'cmid'      => $context->instanceid,
+            'modname'   => 'choice',
+        ];
+
+        $userlist->add_from_sql('userid', $sql, $params);
+    }
+
     /**
      * Export personal data for the given approved_contextlist. User and context information is contained within the contextlist.
      *
@@ -209,8 +244,40 @@ class provider implements
             if (!$context instanceof \context_module) {
                 continue;
             }
-            $instanceid = $DB->get_field('course_modules', 'instance', ['id' => $context->instanceid], MUST_EXIST);
+            $instanceid = $DB->get_field('course_modules', 'instance', ['id' => $context->instanceid]);
+            if (!$instanceid) {
+                continue;
+            }
             $DB->delete_records('choice_answers', ['choiceid' => $instanceid, 'userid' => $userid]);
         }
     }
+
+    /**
+     * Delete multiple users within a single context.
+     *
+     * @param   approved_userlist       $userlist The approved context and user information to delete information for.
+     */
+    public static function delete_data_for_users(approved_userlist $userlist) {
+        global $DB;
+
+        $context = $userlist->get_context();
+
+        if (!$context instanceof \context_module) {
+            return;
+        }
+
+        $cm = get_coursemodule_from_id('choice', $context->instanceid);
+
+        if (!$cm) {
+            // Only choice module will be handled.
+            return;
+        }
+
+        $userids = $userlist->get_userids();
+        list($usersql, $userparams) = $DB->get_in_or_equal($userids, SQL_PARAMS_NAMED);
+
+        $select = "choiceid = :choiceid AND userid $usersql";
+        $params = ['choiceid' => $cm->instance] + $userparams;
+        $DB->delete_records_select('choice_answers', $select, $params);
+    }
 }
index fea7ccf..e076cf9 100644 (file)
@@ -222,4 +222,94 @@ class mod_choice_privacy_provider_testcase extends \core_privacy\tests\provider_
         // And that it's the other student's response.
         $this->assertEquals($otherstudent->id, $lastresponse->userid);
     }
+
+    /**
+     * Test for provider::get_users_in_context().
+     */
+    public function test_get_users_in_context() {
+        $cm = get_coursemodule_from_instance('choice', $this->choice->id);
+        $cmcontext = context_module::instance($cm->id);
+
+        $userlist = new \core_privacy\local\request\userlist($cmcontext, 'mod_choice');
+        \mod_choice\privacy\provider::get_users_in_context($userlist);
+
+        $this->assertEquals(
+                [$this->student->id],
+                $userlist->get_userids()
+        );
+    }
+
+    /**
+     * Test for provider::get_users_in_context() with invalid context type.
+     */
+    public function test_get_users_in_context_invalid_context_type() {
+        $systemcontext = context_system::instance();
+
+        $userlist = new \core_privacy\local\request\userlist($systemcontext, 'mod_choice');
+        \mod_choice\privacy\provider::get_users_in_context($userlist);
+
+        $this->assertCount(0, $userlist->get_userids());
+    }
+
+    /**
+     * Test for provider::delete_data_for_users().
+     */
+    public function test_delete_data_for_users() {
+        global $DB;
+
+        $choice = $this->choice;
+        $generator = $this->getDataGenerator();
+        $cm1 = get_coursemodule_from_instance('choice', $this->choice->id);
+
+        // Create a second choice activity.
+        $options = ['Boracay', 'Camiguin', 'Bohol', 'Cebu', 'Coron'];
+        $params = [
+            'course' => $this->course->id,
+            'option' => $options,
+            'name' => 'Which do you think is the best island in the Philippines?',
+            'showpreview' => 0
+        ];
+        $plugingenerator = $generator->get_plugin_generator('mod_choice');
+        $choice2 = $plugingenerator->create_instance($params);
+        $plugingenerator->create_instance($params);
+        $cm2 = get_coursemodule_from_instance('choice', $choice2->id);
+
+        // Make a selection for the first student for the 2nd choice activity.
+        $choicewithoptions = choice_get_choice($choice2->id);
+        $optionids = array_keys($choicewithoptions->option);
+        choice_user_submit_response($optionids[2], $choice2, $this->student->id, $this->course, $cm2);
+
+        // Create 2 other students who will answer the first choice activity.
+        $otherstudent = $generator->create_and_enrol($this->course, 'student');
+        $anotherstudent = $generator->create_and_enrol($this->course, 'student');
+
+        $choicewithoptions = choice_get_choice($choice->id);
+        $optionids = array_keys($choicewithoptions->option);
+
+        choice_user_submit_response($optionids[1], $choice, $otherstudent->id, $this->course, $cm1);
+        choice_user_submit_response($optionids[1], $choice, $anotherstudent->id, $this->course, $cm1);
+
+        // Before deletion, we should have 3 responses in the first choice activity.
+        $count = $DB->count_records('choice_answers', ['choiceid' => $choice->id]);
+        $this->assertEquals(3, $count);
+
+        $context1 = context_module::instance($cm1->id);
+        $approveduserlist = new \core_privacy\local\request\approved_userlist($context1, 'choice',
+                [$this->student->id, $otherstudent->id]);
+        provider::delete_data_for_users($approveduserlist);
+
+        // After deletion, the choice answers of the 2 students provided above should have been deleted
+        // from the first choice activity. So there should only remain 1 answer which is for $anotherstudent.
+        $choiceanswers = $DB->get_records('choice_answers', ['choiceid' => $choice->id]);
+        $this->assertCount(1, $choiceanswers);
+        $lastresponse = reset($choiceanswers);
+        $this->assertEquals($anotherstudent->id, $lastresponse->userid);
+
+        // Confirm that the answer that was submitted in the other choice activity is intact.
+        $choiceanswers = $DB->get_records_select('choice_answers', 'choiceid <> ?', [$choice->id]);
+        $this->assertCount(1, $choiceanswers);
+        $lastresponse = reset($choiceanswers);
+        // And that it's for the choice2 activity.
+        $this->assertEquals($choice2->id, $lastresponse->choiceid);
+    }
 }
index 6f298b4..825cf8b 100644 (file)
@@ -386,8 +386,8 @@ $string['notinstalled'] = 'The forum module is not installed';
 $string['notpartofdiscussion'] = 'This post is not part of a discussion!';
 $string['notrackforum'] = 'Don\'t track unread posts';
 $string['noviewdiscussionspermission'] = 'You do not have the permission to view discussions in this forum';
-$string['nowallsubscribed'] = 'All forums in {$a} are subscribed.';
-$string['nowallunsubscribed'] = 'All forums in {$a} are not subscribed.';
+$string['nowallsubscribed'] = 'You are now subscribed to all forums in {$a}.';
+$string['nowallunsubscribed'] = 'You are now unsubscribed from all forums in {$a}.';
 $string['nownotsubscribed'] = '{$a->name} will NOT be notified of new posts in \'{$a->forum}\'';
 $string['nownottracking'] = '{$a->name} is no longer tracking \'{$a->forum}\'.';
 $string['nowsubscribed'] = '{$a->name} will be notified of new posts in \'{$a->forum}\'';
index dd9ba65..8fe7e4e 100644 (file)
@@ -425,8 +425,7 @@ $string['return_to_course'] = 'Click <a href="{$a->link}" target="_top">here</a>
 $string['saveallfeedback'] = 'Save all my feedback';
 $string['search:activity'] = 'External tool - activity information';
 $string['secure_icon_url'] = 'Secure icon URL';
-$string['secure_icon_url_help'] = 'Similar to the icon URL, but used if the user accessing Moodle securely through SSL. The main purpose for this field is to prevent
-the browser from warning the user if the underlying page was accessed over SSL, but requesting to show an unsecure image.';
+$string['secure_icon_url_help'] = 'Similar to the icon URL, but used when the site is accessed securely through SSL. This field is to prevent the browser from displaying a warning about an insecure image.';
 $string['secure_launch_url'] = 'Secure tool URL';
 $string['secure_launch_url_help'] = 'Similar to the tool URL, but used instead of the tool URL if high security is required. Moodle will use the secure tool URL instead of the tool URL if the Moodle site is accessed through SSL, or if the tool configuration is set to always launch through SSL.
 
index 3b73a57..23700b2 100644 (file)
@@ -617,7 +617,7 @@ $string['percentcorrect'] = 'Percent correct';
 $string['pleaseclose'] = 'Your request has been processed. You can now close this window';
 $string['pluginadministration'] = 'Quiz administration';
 $string['pluginname'] = 'Quiz';
-$string['popup'] = 'Show quiz in a &quot;secure&quot; window';
+$string['popup'] = 'Show quiz in a \'secure\' window';
 $string['popupblockerwarning'] = 'This section of the test is in secure mode, this means that you need to take the quiz in a secure window. Please turn off your popup blocker. Thank you.';
 $string['popupnotice'] = 'Students will see this quiz in a secure window';
 $string['preprocesserror'] = 'Error occurred during pre-processing!';
@@ -700,7 +700,7 @@ $string['quiz:emailconfirmsubmission'] = 'Get a confirmation message when submit
 $string['quiz:emailnotifysubmission'] = 'Get a notification message when an attempt is submitted';
 $string['quiz:emailwarnoverdue'] = 'Get a notification message when an attempt becomes overdue and needs to be submitted.';
 $string['quiz:grade'] = 'Grade quizzes manually';
-$string['quiz:ignoretimelimits'] = 'Ignores time limit on quizzes';
+$string['quiz:ignoretimelimits'] = 'Ignore quiz time limit';
 $string['quizisclosed'] = 'This quiz is closed';
 $string['quizisopen'] = 'This quiz is open';
 $string['quizisclosedwillopen'] = 'Quiz closed (opens {$a})';
diff --git a/mod/quiz/report/grading/renderer.php b/mod/quiz/report/grading/renderer.php
new file mode 100755 (executable)
index 0000000..69dbd2c
--- /dev/null
@@ -0,0 +1,197 @@
+<?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/>.
+
+/**
+ * Defines the renderer for the quiz_grading module.
+ *
+ * @package   quiz_grading
+ * @copyright 2018 Huong Nguyen <huongnv13@gmail.com>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * The renderer for the quiz_grading module.
+ *
+ * @copyright  2018 Huong Nguyen <huongnv13@gmail.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class quiz_grading_renderer extends plugin_renderer_base {
+
+    /**
+     * Render no question notification.
+     *
+     * @param object $quiz The quiz settings.
+     * @param object $cm The course-module for this quiz.
+     * @param object $context The quiz context.
+     * @return string The HTML for the no questions message.
+     */
+    public function render_quiz_no_question_notification($quiz, $cm, $context) {
+        return quiz_no_questions_message($quiz, $cm, $context);
+    }
+
+    /**
+     * Render no question need to grade notification.
+     *
+     * @throws coding_exception
+     */
+    public function render_quiz_no_grade_question_notification() {
+        return $this->notification(get_string('nothingfound', 'quiz_grading'));
+    }
+
+    /**
+     * Render index display.
+     *
+     * @param string $linktext The text of the link.
+     * @param moodle_url $listquestionurl Url of the page that list all questions.
+     * @return string The HTML for the display heading.
+     * @throws coding_exception
+     */
+    public function render_display_index_heading($linktext, $listquestionurl) {
+        $output = '';
+
+        $output .= $this->heading(get_string('questionsthatneedgrading', 'quiz_grading'), 3);
+        $output .= html_writer::tag('p', html_writer::link($listquestionurl, $linktext), ['class' => 'toggleincludeauto']);
+
+        return $output;
+    }
+
+    /**
+     * Render questions list table.
+     *
+     * @param bool $includeauto True to show automatically graded questions.
+     * @param array $data List of questions.
+     * @param array $header List of table headers.
+     * @return string The HTML for the question table.
+     * @throws coding_exception
+     */
+    public function render_questions_table($includeauto, $data, $header) {
+        if (empty($data)) {
+            return $this->render_quiz_no_grade_question_notification();
+        }
+        $output = '';
+
+        $table = new html_table();
+        $table->class = 'generaltable';
+        $table->id = 'questionstograde';
+        $table->head = $header;
+        $table->data = $data;
+
+        $output .= html_writer::table($table);
+
+        return $output;
+    }
+
+    /**
+     * Render grade link for question.
+     *
+     * @param object $counts
+     * @param string $type Type of grade.
+     * @param string $gradestring Lang string.
+     * @param moodle_url $gradequestionurl Url to grade question.
+     * @return string The HTML for the question grade link.
+     * @throws coding_exception
+     */
+    public function render_grade_link($counts, $type, $gradestring, $gradequestionurl) {
+        $output = '';
+        if ($counts->$type > 0) {
+            $output .= ' ' . html_writer::link(
+                            $gradequestionurl,
+                            get_string($gradestring, 'quiz_grading'),
+                            ['class' => 'gradetheselink']);
+        }
+        return $output;
+    }
+
+    /**
+     * Render grading page.
+     *
+     * @param object $questioninfo Information of a question.
+     * @param moodle_url $listquestionsurl Url of the page that list all questions.
+     * @param quiz_grading_settings_form $filterform Question filter form.
+     * @param object $paginginfo Pagination information.
+     * @param object $pagingbar Pagination bar information.
+     * @param moodle_url $formaction Form submit url.
+     * @param array $hiddeninputs List of hidden input fields.
+     * @param string $gradequestioncontent HTML string of question content.
+     * @return string The HTML for the grading interface.
+     * @throws coding_exception
+     * @throws moodle_exception
+     */
+    public function render_grading_interface($questioninfo, $listquestionsurl, $filterform, $paginginfo, $pagingbar, $formaction,
+            $hiddeninputs, $gradequestioncontent) {
+        $output = '';
+
+        $output .= question_engine::initialise_js();
+
+        $output .= $this->heading(get_string('gradingquestionx', 'quiz_grading', $questioninfo), 3);
+
+        $output .= html_writer::tag('p', html_writer::link($listquestionsurl,
+                get_string('backtothelistofquestions', 'quiz_grading')),
+                ['class' => 'mdl-align']);
+
+        $output .= $filterform->render();
+
+        $output .= $this->heading(get_string('gradingattemptsxtoyofz', 'quiz_grading', $paginginfo), 3);
+
+        if ($pagingbar->count > $pagingbar->pagesize && $pagingbar->order != 'random') {
+            $output .= $this->paging_bar($pagingbar->count, $pagingbar->page, $pagingbar->pagesize, $pagingbar->pagingurl);
+        }
+
+        $output .= html_writer::start_tag('form', [
+                'method' => 'post',
+                'action' => $formaction,
+                'class' => 'mform',
+                'id' => 'manualgradingform'
+        ]);
+        $output .= html_writer::start_tag('div');
+        $output .= html_writer::input_hidden_params(new moodle_url('', $hiddeninputs));
+
+        $output .= $gradequestioncontent;
+
+        $output .= html_writer::tag('div', html_writer::empty_tag('input', [
+                'type' => 'submit',
+                'class' => 'btn btn-primary',
+                'value' => get_string('saveandnext', 'quiz_grading')
+        ]), ['class' => 'mdl-align']);
+        $output .= html_writer::end_tag('div') . html_writer::end_tag('form');
+
+        return $output;
+    }
+
+    /**
+     * Render grade question content.
+     *
+     * @param question_usage_by_activity $questionusage The question usage that need to grade.
+     * @param int $slot the number used to identify this question within this usage.
+     * @param question_display_options $displayoptions the display options to use.
+     * @param int $questionnumber the number of the question to check.
+     * @param string $heading the question heading text.
+     * @return string The HTML for the question display.
+     */
+    public function render_grade_question($questionusage, $slot, $displayoptions, $questionnumber, $heading) {
+        $output = '';
+
+        if ($heading) {
+            $output .= $this->heading($heading, 4);
+        }
+
+        $output .= $questionusage->render_question($slot, $displayoptions, $questionnumber);
+
+        return $output;
+    }
+}
index ecb0355..c4e843f 100644 (file)
@@ -48,6 +48,9 @@ class quiz_grading_report extends quiz_default_report {
     protected $quiz;
     protected $context;
 
+    /** @var renderer_base Renderer of Quiz Grading. */
+    private $renderer;
+
     public function display($quiz, $cm, $course) {
 
         $this->quiz = $quiz;
@@ -143,13 +146,13 @@ class quiz_grading_report extends quiz_default_report {
 
         // What sort of page to display?
         if (!$hasquestions) {
-            echo quiz_no_questions_message($quiz, $cm, $this->context);
+            echo $this->renderer->render_quiz_no_question_notification($quiz, $cm, $this->context);
 
         } else if (!$slot) {
-            $this->display_index($includeauto);
+            echo $this->display_index($includeauto);
 
         } else {
-            $this->display_grading_interface($slot, $questionid, $grade,
+            echo $this->display_grading_interface($slot, $questionid, $grade,
                     $pagesize, $page, $shownames, $showidnumbers, $order, $counts);
         }
         return true;
@@ -257,34 +260,40 @@ class quiz_grading_report extends quiz_default_report {
     protected function format_count_for_table($counts, $type, $gradestring) {
         $result = $counts->$type;
         if ($counts->$type > 0) {
-            $result .= ' ' . html_writer::link($this->grade_question_url(
-                    $counts->slot, $counts->questionid, $type),
-                    get_string($gradestring, 'quiz_grading'),
-                    array('class' => 'gradetheselink'));
+            $gradeurl = $this->grade_question_url($counts->slot, $counts->questionid, $type);
+            $result .= $this->renderer->render_grade_link($counts, $type, $gradestring, $gradeurl);
         }
         return $result;
     }
 
     protected function display_index($includeauto) {
-        global $OUTPUT, $PAGE;
+        global $PAGE;
+        $output = '';
 
         if ($groupmode = groups_get_activity_groupmode($this->cm)) {
             // Groups is being used.
             groups_print_activity_menu($this->cm, $this->list_questions_url());
         }
-
-        echo $OUTPUT->heading(get_string('questionsthatneedgrading', 'quiz_grading'), 3);
+        $statecounts = $this->get_question_state_summary(array_keys($this->questions));
         if ($includeauto) {
             $linktext = get_string('hideautomaticallygraded', 'quiz_grading');
         } else {
             $linktext = get_string('alsoshowautomaticallygraded', 'quiz_grading');
         }
-        echo html_writer::tag('p', html_writer::link($this->list_questions_url(!$includeauto),
-                $linktext), array('class' => 'toggleincludeauto'));
+        $output .= $this->renderer->render_display_index_heading($linktext, $this->list_questions_url(!$includeauto));
+        $data = array();
+        $header = [];
 
-        $statecounts = $this->get_question_state_summary(array_keys($this->questions));
+        $header[] = get_string('qno', 'quiz_grading');
+        $header[] = get_string('qtypeveryshort', 'question');
+        $header[] = get_string('questionname', 'quiz_grading');
+        $header[] = get_string('tograde', 'quiz_grading');
+        $header[] = get_string('alreadygraded', 'quiz_grading');
+        if ($includeauto) {
+            $header[] = get_string('automaticallygraded', 'quiz_grading');
+        }
+        $header[] = get_string('total', 'quiz_grading');
 
-        $data = array();
         foreach ($statecounts as $counts) {
             if ($counts->all == 0) {
                 continue;
@@ -313,33 +322,13 @@ class quiz_grading_report extends quiz_default_report {
 
             $data[] = $row;
         }
-
-        if (empty($data)) {
-            echo $OUTPUT->notification(get_string('nothingfound', 'quiz_grading'));
-            return;
-        }
-
-        $table = new html_table();
-        $table->class = 'generaltable';
-        $table->id = 'questionstograde';
-
-        $table->head[] = get_string('qno', 'quiz_grading');
-        $table->head[] = get_string('qtypeveryshort', 'question');
-        $table->head[] = get_string('questionname', 'quiz_grading');
-        $table->head[] = get_string('tograde', 'quiz_grading');
-        $table->head[] = get_string('alreadygraded', 'quiz_grading');
-        if ($includeauto) {
-            $table->head[] = get_string('automaticallygraded', 'quiz_grading');
-        }
-        $table->head[] = get_string('total', 'quiz_grading');
-
-        $table->data = $data;
-        echo html_writer::table($table);
+        $output .= $this->renderer->render_questions_table($includeauto, $data, $header);
+        return $output;
     }
 
     protected function display_grading_interface($slot, $questionid, $grade,
             $pagesize, $page, $shownames, $showidnumbers, $order, $counts) {
-        global $OUTPUT;
+        $output = '';
 
         if ($pagesize * $page >= $counts->$grade) {
             $page = 0;
@@ -369,41 +358,19 @@ class quiz_grading_report extends quiz_default_report {
         $settings->order = $order;
         $mform->set_data($settings);
 
-        // Print the heading and form.
-        echo question_engine::initialise_js();
-
-        $a = new stdClass();
-        $a->number = $this->questions[$slot]->number;
-        $a->questionname = format_string($counts->name);
-        echo $OUTPUT->heading(get_string('gradingquestionx', 'quiz_grading', $a), 3);
-        echo html_writer::tag('p', html_writer::link($this->list_questions_url(),
-                get_string('backtothelistofquestions', 'quiz_grading')),
-                array('class' => 'mdl-align'));
-
-        $mform->display();
+        // Question info.
+        $questioninfo = new stdClass();
+        $questioninfo->number = $this->questions[$slot]->number;
+        $questioninfo->questionname = format_string($counts->name);
 
         // Paging info.
-        $a = new stdClass();
-        $a->from = $page * $pagesize + 1;
-        $a->to = min(($page + 1) * $pagesize, $count);
-        $a->of = $count;
-        echo $OUTPUT->heading(get_string('gradingattemptsxtoyofz', 'quiz_grading', $a), 3);
-
-        if ($count > $pagesize && $order != 'random') {
-            echo $OUTPUT->paging_bar($count, $page, $pagesize,
-                    $this->grade_question_url($slot, $questionid, $grade, false));
-        }
-
-        // Display the form with one section for each attempt.
-        $sesskey = sesskey();
+        $paginginfo = new stdClass();
+        $paginginfo->from = $page * $pagesize + 1;
+        $paginginfo->to = min(($page + 1) * $pagesize, $count);
+        $paginginfo->of = $count;
         $qubaidlist = implode(',', $qubaids);
-        echo html_writer::start_tag('form', array('method' => 'post',
-                'action' => $this->grade_question_url($slot, $questionid, $grade, $page),
-                'class' => 'mform', 'id' => 'manualgradingform')) .
-                html_writer::start_tag('div') .
-                html_writer::input_hidden_params(new moodle_url('', array(
-                'qubaids' => $qubaidlist, 'slots' => $slot, 'sesskey' => $sesskey)));
 
+        $gradequestioncontent = '';
         foreach ($qubaids as $qubaid) {
             $attempt = $attempts[$qubaid];
             $quba = question_engine::load_questions_usage_by_activity($qubaid);
@@ -413,37 +380,40 @@ class quiz_grading_report extends quiz_default_report {
             $displayoptions->history = question_display_options::HIDDEN;
             $displayoptions->manualcomment = question_display_options::EDITABLE;
 
-            $heading = $this->get_question_heading($attempt, $shownames, $showidnumbers);
-            if ($heading) {
-                echo $OUTPUT->heading($heading, 4);
-            }
-            echo $quba->render_question($slot, $displayoptions, $this->questions[$slot]->number);
+            $gradequestioncontent .= $this->renderer->render_grade_question(
+                    $quba,
+                    $slot,
+                    $displayoptions,
+                    $this->questions[$slot]->number,
+                    $this->get_question_heading($attempt, $shownames, $showidnumbers)
+            );
         }
 
-        echo html_writer::tag('div', html_writer::empty_tag('input', array(
-                'type' => 'submit', 'class' => 'btn btn-primary', 'value' => get_string('saveandnext', 'quiz_grading'))),
-                array('class' => 'mdl-align')) .
-                html_writer::end_tag('div') . html_writer::end_tag('form');
-    }
-
-    protected function get_question_heading($attempt, $shownames, $showidnumbers) {
-        $a = new stdClass();
-        $a->attempt = $attempt->attempt;
-        $a->fullname = fullname($attempt);
-        $a->idnumber = $attempt->idnumber;
-
-        $showidnumbers &= !empty($attempt->idnumber);
-
-        if ($shownames && $showidnumbers) {
-            return get_string('gradingattemptwithidnumber', 'quiz_grading', $a);
-        } else if ($shownames) {
-            return get_string('gradingattempt', 'quiz_grading', $a);
-        } else if ($showidnumbers) {
-            $a->fullname = $attempt->idnumber;
-            return get_string('gradingattempt', 'quiz_grading', $a);
-        } else {
-            return '';
-        }
+        $pagingbar = new stdClass();
+        $pagingbar->count = $count;
+        $pagingbar->page = $page;
+        $pagingbar->pagesize = $pagesize;
+        $pagingbar->pagesize = $pagesize;
+        $pagingbar->order = $order;
+        $pagingbar->pagingurl = $this->grade_question_url($slot, $questionid, $grade, false);
+
+        $hiddeninputs = [
+                'qubaids' => $qubaidlist,
+                'slots' => $slot,
+                'sesskey' => sesskey()
+        ];
+
+        $output .= $this->renderer->render_grading_interface(
+                $questioninfo,
+                $this->list_questions_url(),
+                $mform,
+                $paginginfo,
+                $pagingbar,
+                $this->grade_question_url($slot, $questionid, $grade, $page),
+                $hiddeninputs,
+                $gradequestioncontent
+        );
+        return $output;
     }
 
     protected function validate_submitted_marks() {
@@ -587,4 +557,47 @@ class quiz_grading_report extends quiz_default_report {
         return $dm->load_questions_usages_where_question_in_state($qubaids, $summarystate,
                 $slot, $questionid, $orderby, $params, $limitfrom, $pagesize);
     }
+
+    /**
+     * Initialise some parts of $PAGE and start output.
+     *
+     * @param object $cm the course_module information.
+     * @param object $course the course settings.
+     * @param object $quiz the quiz settings.
+     * @param string $reportmode the report name.
+     */
+    public function print_header_and_tabs($cm, $course, $quiz, $reportmode = 'overview') {
+        global $PAGE;
+        $this->renderer = $PAGE->get_renderer('quiz_grading');
+        parent::print_header_and_tabs($cm, $course, $quiz, $reportmode);
+    }
+
+    /**
+     * Get question heading.
+     *
+     * @param object $attempt an instance of quiz_attempt.
+     * @param bool $shownames True to show the question name.
+     * @param bool $showidnumbers True to show the question id number.
+     * @return string The string text for the question heading.
+     * @throws coding_exception
+     */
+    protected function get_question_heading($attempt, $shownames, $showidnumbers) {
+        $a = new stdClass();
+        $a->attempt = $attempt->attempt;
+        $a->fullname = fullname($attempt);
+        $a->idnumber = $attempt->idnumber;
+
+        $showidnumbers = $showidnumbers && !empty($attempt->idnumber);
+
+        if ($shownames && $showidnumbers) {
+            return get_string('gradingattemptwithidnumber', 'quiz_grading', $a);
+        } else if ($shownames) {
+            return get_string('gradingattempt', 'quiz_grading', $a);
+        } else if ($showidnumbers) {
+            $a->fullname = $attempt->idnumber;
+            return get_string('gradingattempt', 'quiz_grading', $a);
+        } else {
+            return '';
+        }
+    }
 }
index e5b0173..73b9988 100644 (file)
@@ -68,29 +68,29 @@ Feature: Basic use of the Statistics report
     # Question A statistics breakdown.
     And "1" row "Question name" column of "questionstatistics" table should contain "Question A"
     And "1" row "Attempts" column of "questionstatistics" table should contain "3"
-    And "1" row "Facility index" column of "questionstatistics" table should contain "66.67 %"
-    And "1" row "Standard deviation" column of "questionstatistics" table should contain "57.74 %"
-    And "1" row "Random guess score" column of "questionstatistics" table should contain "50.00 %"
-    And "1" row "Intended weight" column of "questionstatistics" table should contain "33.33 %"
-    And "1" row "Effective weight" column of "questionstatistics" table should contain "30.90 %"
-    And "1" row "Discrimination index" column of "questionstatistics" table should contain "50.00 %"
+    And "1" row "Facility index" column of "questionstatistics" table should contain "66.67%"
+    And "1" row "Standard deviation" column of "questionstatistics" table should contain "57.74%"
+    And "1" row "Random guess score" column of "questionstatistics" table should contain "50.00%"
+    And "1" row "Intended weight" column of "questionstatistics" table should contain "33.33%"
+    And "1" row "Effective weight" column of "questionstatistics" table should contain "30.90%"
+    And "1" row "Discrimination index" column of "questionstatistics" table should contain "50.00%"
 
     # Question B statistics breakdown.
     And "2" row "Question name" column of "questionstatistics" table should contain "Question B"
     And "2" row "Attempts" column of "questionstatistics" table should contain "3"
-    And "2" row "Facility index" column of "questionstatistics" table should contain "33.33 %"
-    And "2" row "Standard deviation" column of "questionstatistics" table should contain "57.74 %"
-    And "2" row "Random guess score" column of "questionstatistics" table should contain "50.00 %"
-    And "2" row "Intended weight" column of "questionstatistics" table should contain "33.33 %"
-    And "2" row "Effective weight" column of "questionstatistics" table should contain "34.55 %"
-    And "2" row "Discrimination index" column of "questionstatistics" table should contain "86.60 %"
+    And "2" row "Facility index" column of "questionstatistics" table should contain "33.33%"
+    And "2" row "Standard deviation" column of "questionstatistics" table should contain "57.74%"
+    And "2" row "Random guess score" column of "questionstatistics" table should contain "50.00%"
+    And "2" row "Intended weight" column of "questionstatistics" table should contain "33.33%"
+    And "2" row "Effective weight" column of "questionstatistics" table should contain "34.55%"
+    And "2" row "Discrimination index" column of "questionstatistics" table should contain "86.60%"
 
     # Question C statistics breakdown.
     And "3" row "Question name" column of "questionstatistics" table should contain "Question C"
     And "3" row "Attempts" column of "questionstatistics" table should contain "3"
-    And "3" row "Facility index" column of "questionstatistics" table should contain "33.33 %"
-    And "3" row "Standard deviation" column of "questionstatistics" table should contain "57.74 %"
-    And "3" row "Random guess score" column of "questionstatistics" table should contain "50.00 %"
-    And "3" row "Intended weight" column of "questionstatistics" table should contain "33.33 %"
-    And "3" row "Effective weight" column of "questionstatistics" table should contain "34.55 %"
-    And "3" row "Discrimination index" column of "questionstatistics" table should contain "86.60 %"
+    And "3" row "Facility index" column of "questionstatistics" table should contain "33.33%"
+    And "3" row "Standard deviation" column of "questionstatistics" table should contain "57.74%"
+    And "3" row "Random guess score" column of "questionstatistics" table should contain "50.00%"
+    And "3" row "Intended weight" column of "questionstatistics" table should contain "33.33%"
+    And "3" row "Effective weight" column of "questionstatistics" table should contain "34.55%"
+    And "3" row "Discrimination index" column of "questionstatistics" table should contain "86.60%"
index 45d0bcb..6d86c20 100644 (file)
@@ -45,12 +45,12 @@ class quiz_statistics_statistics_table_testcase extends advanced_testcase {
         $method->setAccessible(true);
 
         $this->assertEquals(
-                '84.758 %',
+                '84.758%',
                 $method->invokeArgs($table, [0.847576, true, 3])
         );
 
         $this->assertEquals(
-                '84.758 %',
+                '84.758%',
                 $method->invokeArgs($table, [84.7576, false, 3])
         );
     }
@@ -64,12 +64,12 @@ class quiz_statistics_statistics_table_testcase extends advanced_testcase {
         $method->setAccessible(true);
 
         $this->assertEquals(
-                '54.400 % − 84.758 %',
+                '54.400% − 84.758%',
                 $method->invokeArgs($table, [0.544, 0.847576, true, 3])
         );
 
         $this->assertEquals(
-                '54.400 % − 84.758 %',
+                '54.400% − 84.758%',
                 $method->invokeArgs($table, [54.4, 84.7576, false, 3])
         );
     }
index 23d9ec3..2cbe51d 100644 (file)
@@ -27,9 +27,9 @@
 defined('MOODLE_INTERNAL') || die();
 
 global $CFG;
+require_once($CFG->dirroot . '/mod/quiz/attemptlib.php');
 require_once($CFG->dirroot . '/mod/quiz/report/reportlib.php');
 
-
 /**
  * This class contains the test cases for the functions in reportlib.php.
  *
@@ -66,9 +66,9 @@ class mod_quiz_reportlib_testcase extends advanced_testcase {
         $quiz->sumgrades = 10;
         $quiz->decimalpoints = 2;
 
-        $this->assertEquals('12.34567 %',
+        $this->assertEquals('12.34567%',
             quiz_report_scale_summarks_as_percentage(1.234567, $quiz, false));
-        $this->assertEquals('12.35 %',
+        $this->assertEquals('12.35%',
             quiz_report_scale_summarks_as_percentage(1.234567, $quiz, true));
         $this->assertEquals('-',
             quiz_report_scale_summarks_as_percentage('-', $quiz, true));
index 82be4ec..00c30a8 100644 (file)
@@ -131,7 +131,7 @@ $string['synchronize'] = 'Synchronise the data from shared datasets with other q
 $string['synchronizeno'] = 'Do not synchronise';
 $string['synchronizeyes'] = 'Synchronise';
 $string['synchronizeyesdisplay'] = 'Synchronise and display the shared datasets name as prefix of the question name';
-$string['tolerance'] = 'Tolerance &plusmn;';
+$string['tolerance'] = 'Tolerance ±';
 $string['tolerancetype'] = 'Type';
 $string['trueanswerinsidelimits'] = 'Correct answer : {$a->correct} inside limits of true value {$a->true}';
 $string['trueansweroutsidelimits'] = '<span class="error">ERROR Correct answer : {$a->correct} outside limits of true value {$a->true}</span>';
index 24330cf..9e4ebb8 100644 (file)
@@ -59,7 +59,7 @@ $string['partiallycorrectfeedback'] = 'For any partially correct response';
 $string['pleaseselectananswer'] = 'Please select an answer.';
 $string['pleaseselectatleastoneanswer'] = 'Please select at least one answer.';
 $string['pluginname'] = 'Multiple choice';
-$string['pluginname_help'] = 'In response to a question (that may include a image) the respondent chooses from multiple answers. There are two types of multiple choice questions - one answer and multiple answer.';
+$string['pluginname_help'] = 'In response to a question (that may include an image) the respondent chooses from multiple answers. A multiple choice question may have one or multiple correct answers.';
 $string['pluginname_link'] = 'question/type/multichoice';
 $string['pluginnameadding'] = 'Adding a Multiple choice question';
 $string['pluginnameediting'] = 'Editing a Multiple choice question';
index 6d905fb..9546fec 100644 (file)
@@ -36,7 +36,7 @@ $string['filloutoneanswer'] = 'You must provide at least one possible answer. An
 $string['notenoughanswers'] = 'This type of question requires at least {$a} answers';
 $string['pleaseenterananswer'] = 'Please enter an answer.';
 $string['pluginname'] = 'Short answer';
-$string['pluginname_help'] = 'In response to a question (that may include a image) the respondent types a word or short phrase. There may be several possible correct answers, each with a different grade. If the "Case sensitive" option is selected, then you can have different scores for "Word" or "word".';
+$string['pluginname_help'] = 'In response to a question (that may include an image) the respondent types a word or short phrase. There may be several possible correct answers, each with a different grade. If the "Case sensitive" option is selected, then you can have different scores for "Word" or "word".';
 $string['pluginname_link'] = 'question/type/shortanswer';
 $string['pluginnameadding'] = 'Adding a short answer question';
 $string['pluginnameediting'] = 'Editing a Short answer question';
index 872d858..d14a5c7 100644 (file)
@@ -33,7 +33,7 @@ $string['pleaseselectananswer'] = 'Please select an answer.';
 $string['selectone'] = 'Select one:';
 $string['true'] = 'True';
 $string['pluginname'] = 'True/False';
-$string['pluginname_help'] = 'In response to a question (that may include a image) the respondent chooses from true or false.';
+$string['pluginname_help'] = 'In response to a question (that may include an image) the respondent chooses from true or false.';
 $string['pluginname_link'] = 'question/type/truefalse';
 $string['pluginnameadding'] = 'Adding a True/False question';
 $string['pluginnameediting'] = 'Editing a True/False question';
index 17e36c1..595c94b 100644 (file)
@@ -30,7 +30,7 @@ $string['check_cachejs_comment_disable'] = 'If enabled, page loading performance
 $string['check_cachejs_comment_enable'] = 'If disabled, page might load slow.';
 $string['check_cachejs_details'] = 'Javascript caching and compression greatly improves page loading performance. It is strongly recommended for production sites.';
 $string['check_debugmsg_comment_nodeveloper'] = 'If set to DEVELOPER, performance may be affected slightly.';
-$string['check_debugmsg_comment_developer'] = 'If set other then DEVELOPER, performance may be improved slightly.';
+$string['check_debugmsg_comment_developer'] = 'If set to a value other than DEVELOPER, performance may be improved slightly.';
 $string['check_debugmsg_details'] = 'There is rarely any advantage in going to Developer level, unless you are a developer, in which case it is strongly recommended.<p>Once you have got the error message, and copied and pasted it somewhere. HIGHLY RECOMMENDED to turn Debug back to NONE. Debug messages can give clues to a hacker as to the setup of your site and may affect performance.</p>';
 $string['check_enablestats_comment_disable'] = 'Performance may be affected by statistics processing. If enabled, statistics settings should be set with caution.';
 $string['check_enablestats_comment_enable'] = 'Performance may be affected by statistics processing. Statistics settings should be set with caution.';
index 5db4f4f..9eb63ad 100644 (file)
@@ -29,6 +29,8 @@ defined('MOODLE_INTERNAL') || die();
 use \core_privacy\local\metadata\collection;
 use \core_privacy\local\request\contextlist;
 use \core_privacy\local\request\approved_contextlist;
+use \core_privacy\local\request\userlist;
+use \core_privacy\local\request\approved_userlist;
 
 /**
  * Privacy Subsystem for report_stats implementing provider.
@@ -36,7 +38,10 @@ use \core_privacy\local\request\approved_contextlist;
  * @copyright  2018 Zig Tan <zig@moodle.com>
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
-class provider implements \core_privacy\local\metadata\provider, \core_privacy\local\request\subsystem\provider{
+class provider implements
+        \core_privacy\local\metadata\provider,
+        \core_privacy\local\request\core_userlist_provider,
+        \core_privacy\local\request\subsystem\provider{
 
     /**
      * Returns information about the user data stored in this component.
@@ -111,6 +116,51 @@ class provider implements \core_privacy\local\metadata\provider, \core_privacy\l
         return $contextlist;
     }
 
+    /**
+     * Get the list of users within a specific context.
+     *
+     * @param userlist $userlist The userlist containing the list of users who have data in this context/plugin combination.
+     */
+    public static function get_users_in_context(userlist $userlist) {
+        $context = $userlist->get_context();
+
+        if (!$context instanceof \context_course) {
+            return;
+        }
+
+        $params = [
+            'contextid' => $context->id,
+            'contextcourse' => CONTEXT_COURSE,
+        ];
+
+        $sql = "SELECT sud.userid
+                  FROM {stats_user_daily} sud
+                  JOIN {context} ctx
+                       ON ctx.instanceid = sud.courseid
+                       AND ctx.contextlevel = :contextcourse
+                 WHERE ctx.id = :contextid";
+
+        $userlist->add_from_sql('userid', $sql, $params);
+
+        $sql = "SELECT suw.userid
+                  FROM {stats_user_weekly} suw
+                  JOIN {context} ctx
+                       ON ctx.instanceid = suw.courseid
+                       AND ctx.contextlevel = :contextcourse
+                 WHERE ctx.id = :contextid";
+
+        $userlist->add_from_sql('userid', $sql, $params);
+
+        $sql = "SELECT sum.userid
+                  FROM {stats_user_monthly} sum
+                  JOIN {context} ctx
+                       ON ctx.instanceid = sum.courseid
+                       AND ctx.contextlevel = :contextcourse
+                 WHERE ctx.id = :contextid";
+
+        $userlist->add_from_sql('userid', $sql, $params);
+    }
+
     /**
      * Export all user data for the specified user, in the specified contexts.
      *
@@ -204,6 +254,27 @@ class provider implements \core_privacy\local\metadata\provider, \core_privacy\l
         }
     }
 
+    /**
+     * Delete multiple users within a single context.
+     *
+     * @param approved_userlist $userlist The approved context and user information to delete information for.
+     */
+    public static function delete_data_for_users(approved_userlist $userlist) {
+        global $DB;
+
+        $context = $userlist->get_context();
+
+        if ($context instanceof \context_course) {
+            list($usersql, $userparams) = $DB->get_in_or_equal($userlist->get_userids(), SQL_PARAMS_NAMED);
+            $select = "courseid = :courseid AND userid {$usersql}";
+            $params = ['courseid' => $context->instanceid] + $userparams;
+
+            $DB->delete_records_select('stats_user_daily', $select, $params);
+            $DB->delete_records_select('stats_user_weekly', $select, $params);
+            $DB->delete_records_select('stats_user_monthly', $select, $params);
+        }
+    }
+
     /**
      * Deletes stats for a given course.
      *
index 982030f..b79607c 100644 (file)
@@ -24,6 +24,9 @@
 
 defined('MOODLE_INTERNAL') || die();
 
+use \report_stats\privacy\provider;
+use \core_privacy\local\request\approved_userlist;
+
 /**
  * Class report_stats_privacy_testcase
  *
@@ -76,13 +79,13 @@ class report_stats_privacy_testcase extends advanced_testcase {
         $this->create_stats($course2->id, $user1->id, 'stats_user_monthly');
         $this->create_stats($course1->id, $user2->id, 'stats_user_weekly');
 
-        $contextlist = \report_stats\privacy\provider::get_contexts_for_userid($user1->id);
+        $contextlist = provider::get_contexts_for_userid($user1->id);
         $this->assertCount(2, $contextlist->get_contextids());
         foreach ($contextlist->get_contexts() as $context) {
             $this->assertEquals(CONTEXT_COURSE, $context->contextlevel);
             $this->assertNotEquals($context3, $context);
         }
-        $contextlist = \report_stats\privacy\provider::get_contexts_for_userid($user2->id);
+        $contextlist = provider::get_contexts_for_userid($user2->id);
         $this->assertCount(1, $contextlist->get_contextids());
         $this->assertEquals($context1, $contextlist->current());
     }
@@ -105,7 +108,7 @@ class report_stats_privacy_testcase extends advanced_testcase {
 
         $approvedlist = new \core_privacy\local\request\approved_contextlist($user, 'report_stats', [$context1->id, $context2->id]);
 
-        \report_stats\privacy\provider::export_user_data($approvedlist);
+        provider::export_user_data($approvedlist);
         $writer = \core_privacy\local\request\writer::with_context($context1);
         $dailystats = (array) $writer->get_data([get_string('privacy:dailypath', 'report_stats')]);
         $this->assertCount(2, $dailystats);
@@ -154,7 +157,7 @@ class report_stats_privacy_testcase extends advanced_testcase {
         $this->assertCount(2, $monthlyrecords);
 
         // Delete all user data for course 1.
-        \report_stats\privacy\provider::delete_data_for_all_users_in_context($context1);
+        provider::delete_data_for_all_users_in_context($context1);
         $dailyrecords = $DB->get_records('stats_user_daily');
         $this->assertCount(1, $dailyrecords);
         $weeklyrecords = $DB->get_records('stats_user_weekly');
@@ -194,7 +197,7 @@ class report_stats_privacy_testcase extends advanced_testcase {
 
         // Delete all user data for course 1.
         $approvedlist = new \core_privacy\local\request\approved_contextlist($user1, 'report_stats', [$context1->id]);
-        \report_stats\privacy\provider::delete_data_for_user($approvedlist);
+        provider::delete_data_for_user($approvedlist);
         $dailyrecords = $DB->get_records('stats_user_daily');
         $this->assertCount(1, $dailyrecords);
         $weeklyrecords = $DB->get_records('stats_user_weekly');
@@ -202,4 +205,116 @@ class report_stats_privacy_testcase extends advanced_testcase {
         $monthlyrecords = $DB->get_records('stats_user_monthly');
         $this->assertCount(1, $monthlyrecords);
     }
+
+    /**
+     * Test that only users within a course context are fetched.
+     */
+    public function test_get_users_in_context() {
+        $this->resetAfterTest();
+
+        $component = 'report_stats';
+
+        // Create user1.
+        $user1 = $this->getDataGenerator()->create_user();
+        // Create user2.
+        $user2 = $this->getDataGenerator()->create_user();
+        // Create course1.
+        $course1 = $this->getDataGenerator()->create_course();
+        $coursecontext1 = context_course::instance($course1->id);
+        // Create course2.
+        $course2 = $this->getDataGenerator()->create_course();
+        $coursecontext2 = context_course::instance($course2->id);
+
+        $userlist1 = new \core_privacy\local\request\userlist($coursecontext1, $component);
+        provider::get_users_in_context($userlist1);
+        $this->assertCount(0, $userlist1);
+
+        $userlist2 = new \core_privacy\local\request\userlist($coursecontext2, $component);
+        provider::get_users_in_context($userlist2);
+        $this->assertCount(0, $userlist2);
+
+        $this->create_stats($course1->id, $user1->id, 'stats_user_daily');
+        $this->create_stats($course2->id, $user1->id, 'stats_user_monthly');
+        $this->create_stats($course1->id, $user2->id, 'stats_user_weekly');
+
+        // The list of users within the course context should contain users.
+        provider::get_users_in_context($userlist1);
+        $this->assertCount(2, $userlist1);
+        $this->assertTrue(in_array($user1->id, $userlist1->get_userids()));
+        $this->assertTrue(in_array($user2->id, $userlist1->get_userids()));
+
+        provider::get_users_in_context($userlist2);
+        $this->assertCount(1, $userlist2);
+        $this->assertTrue(in_array($user1->id, $userlist2->get_userids()));
+
+        // The list of users within other contexts than course should be empty.
+        $systemcontext = context_system::instance();
+        $userlist3 = new \core_privacy\local\request\userlist($systemcontext, $component);
+        provider::get_users_in_context($userlist3);
+        $this->assertCount(0, $userlist3);
+    }
+
+    /**
+     * Test that data for users in approved userlist is deleted.
+     */
+    public function test_delete_data_for_users() {
+        $this->resetAfterTest();
+
+        $component = 'report_stats';
+
+        // Create user1.
+        $user1 = $this->getDataGenerator()->create_user();
+        // Create user2.
+        $user2 = $this->getDataGenerator()->create_user();
+        // Create user3.
+        $user3 = $this->getDataGenerator()->create_user();
+        // Create course1.
+        $course1 = $this->getDataGenerator()->create_course();
+        $coursecontext1 = context_course::instance($course1->id);
+        // Create course2.
+        $course2 = $this->getDataGenerator()->create_course();
+        $coursecontext2 = context_course::instance($course2->id);
+
+        $this->create_stats($course1->id, $user1->id, 'stats_user_daily');
+        $this->create_stats($course2->id, $user1->id, 'stats_user_monthly');
+        $this->create_stats($course1->id, $user2->id, 'stats_user_weekly');
+        $this->create_stats($course1->id, $user3->id, 'stats_user_weekly');
+
+        $userlist1 = new \core_privacy\local\request\userlist($coursecontext1, $component);
+        provider::get_users_in_context($userlist1);
+        $this->assertCount(3, $userlist1);
+
+        $userlist2 = new \core_privacy\local\request\userlist($coursecontext2, $component);
+        provider::get_users_in_context($userlist2);
+        $this->assertCount(1, $userlist2);
+
+        // Convert $userlist1 into an approved_contextlist.
+        $approvedlist1 = new approved_userlist($coursecontext1, $component, [$user1->id, $user2->id]);
+        // Delete using delete_data_for_user.
+        provider::delete_data_for_users($approvedlist1);
+
+        // Re-fetch users in coursecontext1.
+        $userlist1 = new \core_privacy\local\request\userlist($coursecontext1, $component);
+        provider::get_users_in_context($userlist1);
+        // The approved user data in coursecontext1 should be deleted.
+        // The user list should still return user3.
+        $this->assertCount(1, $userlist1);
+        $this->assertTrue(in_array($user3->id, $userlist1->get_userids()));
+        // Re-fetch users in coursecontext2.
+        $userlist2 = new \core_privacy\local\request\userlist($coursecontext2, $component);
+        provider::get_users_in_context($userlist2);
+        // The user data in coursecontext2 should be still present.
+        $this->assertCount(1, $userlist2);
+
+        // Convert $userlist2 into an approved_contextlist in the system context.
+        $systemcontext = context_system::instance();
+        $approvedlist2 = new approved_userlist($systemcontext, $component, $userlist2->get_userids());
+        // Delete using delete_data_for_user.
+        provider::delete_data_for_users($approvedlist2);
+        // Re-fetch users in coursecontext2.
+        $userlist2 = new \core_privacy\local\request\userlist($coursecontext2, $component);
+        provider::get_users_in_context($userlist2);
+        // The user data in systemcontext should not be deleted.
+        $this->assertCount(1, $userlist2);
+    }
 }
index 0c260d4..354af14 100644 (file)
@@ -82,7 +82,7 @@ $THEME->layouts = [
         'file' => 'columns2.php',
         'regions' => array('side-pre'),
         'defaultregion' => 'side-pre',
-        'options' => array('nonavbar' => true, 'langmenu' => true),
+        'options' => array('nonavbar' => true, 'langmenu' => true, 'nocontextheader' => true),
     ),
     // My public page.
     'mypublic' => array(
index d5c843d..1b9da99 100644 (file)
@@ -16,6 +16,7 @@ $breadcrumb-divider-rtl: "◀" !default;
 @import "moodle/calendar";
 @import "moodle/course";
 @import "moodle/drawer";
+@import "moodle/dashboard";
 @import "moodle/filemanager";
 @import "moodle/message";
 @import "moodle/question";
index 6494af8..c4cc09b 100644 (file)
@@ -25,7 +25,7 @@
     }
 }
 
-$blocks-column-width: 250px !default;
+$blocks-column-width: 360px !default;
 
 [data-region="blocks-column"] {
     width: $blocks-column-width;
@@ -108,11 +108,12 @@ $card-gutter : $card-deck-margin * 2;
             background-color: $gray-200;
         }
     }
-    @include media-breakpoint-down(sm) {
-        .summaryimage {
-            max-height: 7rem;
-        }
-    }
+}
+
+.summaryimage {
+    height: 7rem;
+    background-position: center;
+    background-size: cover;
 }
 
 .dashboard-card-deck .dashboard-card {
index 0bc6d4c..00cc1e6 100644 (file)
@@ -1139,6 +1139,15 @@ span.editinstructions {
     opacity: 0.5;
 }
 
+.course-header-image-wrapper {
+    width: 100px;
+    height: 100px;
+    .course-header-image {
+        background-size: cover;
+        background-position: center;
+    }
+}
+
 /**
  * Display sizes:
  * Large displays                   1200        +
diff --git a/theme/boost/scss/moodle/dashboard.scss b/theme/boost/scss/moodle/dashboard.scss
new file mode 100644 (file)
index 0000000..64b6573
--- /dev/null
@@ -0,0 +1,4 @@
+// Background color change as of MDL-63042
+#page-my-index {
+    background-color: $gray-100;
+}
\ No newline at end of file
index 2b9922b..a0e4db7 100644 (file)
@@ -11094,7 +11094,7 @@ div.editor_atto_toolbar button .icon {
   color: #373a3c; }
 
 [data-region="blocks-column"] {
-  width: 250px;
+  width: 360px;
   float: right; }
 
 /* We put an absolutely positioned div in a relatively positioned div so it takes up no space */
@@ -11121,7 +11121,7 @@ div.editor_atto_toolbar button .icon {
 #region-main-settings-menu.has-blocks,
 #region-main.has-blocks {
   display: inline-block;
-  width: calc(100% - 265px); }
+  width: calc(100% - 375px); }
   @media (max-width: 1199.98px) {
     #region-main-settings-menu.has-blocks,
     #region-main.has-blocks {
@@ -11162,9 +11162,10 @@ div.editor_atto_toolbar button .icon {
   .block_myoverview .btn.btn-link.btn-icon:hover, .block_myoverview #page-grade-grading-manage .actions .btn-link.btn-icon.action:hover, #page-grade-grading-manage .actions .block_myoverview .btn-link.btn-icon.action:hover, .block_myoverview #rubric-rubric.gradingform_rubric #rubric-criteria .criterion .addlevel input.btn-link.btn-icon:hover, #rubric-rubric.gradingform_rubric #rubric-criteria .criterion .addlevel .block_myoverview input.btn-link.btn-icon:hover, .block_myoverview #rubric-rubric.gradingform_rubric .btn-link.btn-icon.addcriterion:hover, #rubric-rubric.gradingform_rubric .block_myoverview .btn-link.btn-icon.addcriterion:hover, .block_myoverview .btn.btn-link.btn-icon:focus, .block_myoverview #page-grade-grading-manage .actions .btn-link.btn-icon.action:focus, #page-grade-grading-manage .actions .block_myoverview .btn-link.btn-icon.action:focus, .block_myoverview #rubric-rubric.gradingform_rubric #rubric-criteria .criterion .addlevel input.btn-link.btn-icon:focus, #rubric-rubric.gradingform_rubric #rubric-criteria .criterion .addlevel .block_myoverview input.btn-link.btn-icon:focus, .block_myoverview #rubric-rubric.gradingform_rubric .btn-link.btn-icon.addcriterion:focus, #rubric-rubric.gradingform_rubric .block_myoverview .btn-link.btn-icon.addcriterion:focus {
     background-color: #e9ecef; }
 
-@media (max-width: 767.98px) {
-  .block_myoverview .summaryimage {
-    max-height: 7rem; } }
+.summaryimage {
+  height: 7rem;
+  background-position: center;
+  background-size: cover; }
 
 .dashboard-card-deck .dashboard-card {
   margin-bottom: 0.5rem;
@@ -12341,6 +12342,13 @@ span.editinstructions {
 .course-being-dragged {
   opacity: 0.5; }
 
+.course-header-image-wrapper {
+  width: 100px;
+  height: 100px; }
+  .course-header-image-wrapper .course-header-image {
+    background-size: cover;
+    background-position: center; }
+
 /**
  * Display sizes:
  * Large displays                   1200        +
@@ -12439,6 +12447,9 @@ body.drawer-ease {
   body.drawer-open-right {
     margin-right: 285px; } }
 
+#page-my-index {
+  background-color: #f8f9fa; }
+
 .fp-content-center {
   height: 100%;
   width: 100%;
index 4ff79b6..7047055 100644 (file)
@@ -68,7 +68,7 @@
                     <div> {{{ output.region_main_settings_menu }}} </div>
                 </div>
                 {{/hasregionmainsettingsmenu}}
-                <section id="region-main" {{#hasblocks}}class="has-blocks mb-2"{{/hasblocks}}>
+                <section id="region-main" {{#hasblocks}}class="has-blocks mb-3"{{/hasblocks}}>
 
                     {{#hasregionmainsettingsmenu}}
                         <div class="region_main_settings_menu_proxy"></div>
index 7123185..f4de2a3 100644 (file)
@@ -20,7 +20,7 @@
 
 {{! Start Block Container }}
 <section id="{{id}}"
-     class="{{#hidden}}hidden{{/hidden}} block block_{{type}} {{#hascontrols}}block_with_controls{{/hascontrols}} card mb-2"
+     class="{{#hidden}}hidden{{/hidden}} block block_{{type}} {{#hascontrols}}block_with_controls{{/hascontrols}} card mb-3"
      role="{{ariarole}}"
      data-block="{{type}}"
      {{#arialabel}}
@@ -33,7 +33,7 @@
      {{/arialabel}}>
 
     {{! Block contents }}
-    <div class="card-body">
+    <div class="card-body p-3">
 
         {{! Block header }}
         {{#title}}
index fc0b577..44a5067 100644 (file)
     along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
 }}
 {{!
+    @template boost/header
+
+    This template renders the header.
+
+    Example context (json):
+    {
+        "contextheader": "context_header_html",
+        "settingsmenu": "settings_html",
+        "hasnavbar": false,
+        "navbar": "navbar_if_available",
+        "courseheader": "course_header_html"
+    }
+
     Page header.
 }}
 <header id="page-header" class="row">
     <div class="col-12 pt-3 pb-3">
-        <div class="card">
-            <div class="card-body">
+        <div class="card {{^contextheader}}border-0{{/contextheader}}">
+            <div class="card-body {{^contextheader}}p-2{{/contextheader}}">
                 <div class="d-flex">
+                    {{#contextheader}}
                     <div class="mr-auto">
-                    {{{contextheader}}}
+                        {{{contextheader}}}
                     </div>
+                    {{/contextheader}}
 
                     {{#settingsmenu}}
                     <div class="context-header-settings-menu">
index cb2acaa..d5ee09e 100644 (file)
@@ -88,7 +88,7 @@ $THEME->layouts = array(
         'file' => 'columns3.php',
         'regions' => array('side-pre', 'side-post'),
         'defaultregion' => 'side-pre',
-        'options' => array('langmenu' => true),
+        'options' => array('langmenu' => true, 'nocontextheader' => true),
     ),
     // My public page.
     'mypublic' => array(
index 77033a3..10c58a6 100644 (file)
     background-position: center;
     background-size: cover;
     .border-top-radius(@baseBorderRadius);
+}
+
+.summaryimage {
+    height: 7rem;
+    background-position: center;
+    background-size: cover;
+}
+
+.position-absolute {
+    position: absolute;
 }
\ No newline at end of file
index 9c26f9a..0b0dceb 100644 (file)
@@ -1124,6 +1124,17 @@ span.editinstructions {
     .opacity(50);
 }
 
+.course-header-image-wrapper {
+    width: 100px;
+    height: 100px;
+    .course-header-image {
+        width: 100%;
+        height: 100%;
+        background-size: cover;
+        background-position: center;
+    }
+}
+
 /**
  * Display sizes:
  * Large displays                   1200        +
index 4759420..2fd008a 100644 (file)
@@ -7004,6 +7004,16 @@ span.editinstructions {
   opacity: 0.5;
   filter: alpha(opacity=50);
 }
+.course-header-image-wrapper {
+  width: 100px;
+  height: 100px;
+}
+.course-header-image-wrapper .course-header-image {
+  width: 100%;
+  height: 100%;
+  background-size: cover;
+  background-position: center;
+}
 /**
  * Display sizes:
  * Large displays                   1200        +
@@ -16644,6 +16654,14 @@ body {
   -moz-border-radius-topleft: 4px;
   border-top-left-radius: 4px;
 }
+.summaryimage {
+  height: 7rem;
+  background-position: center;
+  background-size: cover;
+}
+.position-absolute {
+  position: absolute;
+}
 /**
  * Moodle forms HTML isn't changeable via renderers (yet?) so this
  * .less file imports styles from the bootstrap @variables file and
index 3b66a8e..815ceef 100644 (file)
@@ -29,7 +29,7 @@
 
 defined('MOODLE_INTERNAL') || die();
 
-$version  = 2018102700.00;              // YYYYMMDD      = weekly release date of this DEV branch.
+$version  = 2018103000.00;              // YYYYMMDD      = weekly release date of this DEV branch.
                                         //         RR    = release increments - 00 in DEV branches.
                                         //           .XX = incremental changes.