Merge branch 'MDL-61197-master' of git://github.com/jleyva/moodle
authorEloy Lafuente (stronk7) <stronk7@moodle.org>
Tue, 30 Oct 2018 23:22:30 +0000 (00:22 +0100)
committerEloy Lafuente (stronk7) <stronk7@moodle.org>
Tue, 30 Oct 2018 23:22:30 +0000 (00:22 +0100)
115 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
comment/classes/privacy/provider.php
course/externallib.php
course/tests/externallib_test.php
course/upgrade.txt
enrol/imsenterprise/lang/en/enrol_imsenterprise.php
enrol/paypal/classes/privacy/provider.php
enrol/paypal/tests/privacy_provider_test.php
grade/grading/classes/privacy/provider.php
grade/grading/form/guide/tests/privacy_test.php
grade/grading/tests/fixtures/marking_guide.php
grade/grading/tests/privacy_test.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/classes/privacy/assign_plugin_request_data.php
mod/assign/classes/privacy/assignfeedback_user_provider.php [new file with mode: 0644]
mod/assign/classes/privacy/assignsubmission_user_provider.php [new file with mode: 0644]
mod/assign/classes/privacy/provider.php
mod/assign/feedback/comments/classes/privacy/provider.php
mod/assign/feedback/comments/tests/privacy_test.php
mod/assign/feedback/editpdf/classes/privacy/provider.php
mod/assign/feedback/editpdf/tests/privacy_test.php
mod/assign/feedback/file/classes/privacy/provider.php
mod/assign/feedback/file/tests/privacy_test.php
mod/assign/feedback/offline/lang/en/assignfeedback_offline.php
mod/assign/lang/en/assign.php
mod/assign/submission/comments/classes/privacy/provider.php
mod/assign/submission/comments/tests/privacy_test.php
mod/assign/submission/file/classes/privacy/provider.php
mod/assign/submission/file/tests/privacy_test.php
mod/assign/submission/onlinetext/classes/privacy/provider.php
mod/assign/submission/onlinetext/tests/privacy_test.php
mod/assign/tests/privacy_test.php
mod/choice/classes/privacy/provider.php
mod/choice/tests/privacy_provider_test.php
mod/data/classes/privacy/provider.php
mod/forum/lang/en/forum.php
mod/glossary/classes/privacy/provider.php
mod/lti/lang/en/lti.php
mod/quiz/classes/privacy/provider.php
mod/quiz/lang/en/quiz.php
mod/quiz/report/grading/renderer.php [new file with mode: 0644]
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/privacy_provider_test.php
mod/quiz/tests/reportlib_test.php
mod/wiki/classes/privacy/provider.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
theme/bootstrapbase/templates/core/paged_content_paging_bar.mustache
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 96db6f4..7ca3b46 100644 (file)
@@ -235,17 +235,29 @@ class provider implements \core_privacy\local\metadata\provider, \core_privacy\l
      * @param   string      $alias An alias prefix to use for comment selects to avoid interference with your own sql.
      * @param   string      $component The component to check.
      * @param   string      $area The comment area to check.
+     * @param   int         $contextid The context id.
      * @param   string      $insql The SQL to use in a sub-select for the itemid query.
      * @param   array       $params The params required for the insql.
      */
     public static function get_users_in_context_from_sql(
-            userlist $userlist, string $alias, string $component, string $area, string $insql, $params) {
+                userlist $userlist, string $alias, string $component, string $area, int $contextid = null, string $insql = '',
+                array $params = []) {
+
+        if ($insql != '') {
+            $insql = "AND {$alias}.itemid {$insql}";
+        }
+        $contextsql = '';
+        if (isset($contextid)) {
+            $contextsql = "AND {$alias}.contextid = :{$alias}contextid";
+            $params["{$alias}contextid"] = $contextid;
+        }
+
         // Comment authors.
         $sql = "SELECT {$alias}.userid
                   FROM {comments} {$alias}
                  WHERE {$alias}.component = :{$alias}component
                    AND {$alias}.commentarea = :{$alias}commentarea
-                   AND {$alias}.itemid IN ({$insql})";
+                   $contextsql $insql";
 
         $params["{$alias}component"] = $component;
         $params["{$alias}commentarea"] = $area;
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..71a4e07 100644 (file)
@@ -809,15 +809,23 @@ class core_course_externallib_testcase extends externallib_advanced_testcase {
         global $DB, $CFG;
 
         $CFG->allowstealth = 1; // Allow stealth activities.
+        $CFG->enablecompletion = true;
+        $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 +838,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.
@@ -872,8 +881,10 @@ class core_course_externallib_testcase extends externallib_advanced_testcase {
      * Test get_course_contents
      */
     public function test_get_course_contents() {
+        global $CFG;
         $this->resetAfterTest(true);
 
+        $CFG->forum_allowforcedreadtracking = 1;
         list($course, $forumcm, $datacm, $pagecm, $labelcm, $urlcm) = $this->prepare_get_course_contents_test();
 
         // We first run the test as admin.
@@ -891,7 +902,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 +911,22 @@ 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;
             }
         }
-        $this->assertEquals(2, $testexecuted);
+        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;
+            }
+        }
+
+        $CFG->forum_allowforcedreadtracking = 0;    // Recover original value.
+        forum_tp_count_forum_unread_posts($forumcm, $course, true);    // Reset static cache for further tests.
+
+        $this->assertEquals(5, $testexecuted);
         $this->assertEquals(0, $sections[0]['section']);
 
         $this->assertCount(5, $sections[0]['modules']);
@@ -1119,6 +1144,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 28b7816..0013e74 100644 (file)
@@ -3,9 +3,15 @@ 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_group_external::get_course_user_groups now can return all user courses group information.
+ * 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)
+ * External function core_group_external::get_course_user_groups now can return all user courses group information.
 
 === 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 cc003e3..5a4beb4 100644 (file)
@@ -29,8 +29,9 @@ defined('MOODLE_INTERNAL') || die();
 
 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\helper;
+use core_privacy\local\request\userlist;
 use core_privacy\local\request\writer;
 
 /**
@@ -40,8 +41,14 @@ use core_privacy\local\request\writer;
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
 class provider implements
+        // Transactions store user data.
         \core_privacy\local\metadata\provider,
-        \core_privacy\local\request\plugin\provider {
+
+        // The paypal enrolment plugin contains user's transactions.
+        \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 {
 
     /**
      * Returns meta data about this system.
@@ -108,17 +115,11 @@ class provider implements
                   FROM {enrol_paypal} ep
                   JOIN {enrol} e ON ep.instanceid = e.id
                   JOIN {context} ctx ON e.courseid = ctx.instanceid AND ctx.contextlevel = :contextcourse
-             LEFT JOIN {user} u ON u.id = :emailuserid AND (
-                    LOWER(u.email) = ep.receiver_email
-                        OR
-                    LOWER(u.email) = ep.business
-                )
-                 WHERE ep.userid = :userid
-                       OR u.id IS NOT NULL";
+                  JOIN {user} u ON u.id = ep.userid OR LOWER(u.email) = ep.receiver_email OR LOWER(u.email) = ep.business
+                 WHERE u.id = :userid";
         $params = [
             'contextcourse' => CONTEXT_COURSE,
             'userid'        => $userid,
-            'emailuserid'   => $userid,
         ];
 
         $contextlist->add_from_sql($sql, $params);
@@ -126,6 +127,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) {
+        $context = $userlist->get_context();
+
+        if (!$context instanceof \context_course) {
+            return;
+        }
+
+        // Values of ep.receiver_email and ep.business are already normalised to lowercase characters by PayPal,
+        // therefore there is no need to use LOWER() on them in the following query.
+        $sql = "SELECT u.id
+                  FROM {enrol_paypal} ep
+                  JOIN {enrol} e ON ep.instanceid = e.id
+                  JOIN {user} u ON ep.userid = u.id OR LOWER(u.email) = ep.receiver_email OR LOWER(u.email) = ep.business
+                 WHERE e.courseid = :courseid";
+        $params = ['courseid' => $context->instanceid];
+
+        $userlist->add_from_sql('id', $sql, $params);
+    }
+
     /**
      * Export all user data for the specified user, in the specified contexts.
      *
@@ -148,14 +173,8 @@ class provider implements
                   FROM {enrol_paypal} ep
                   JOIN {enrol} e ON ep.instanceid = e.id
                   JOIN {context} ctx ON e.courseid = ctx.instanceid AND ctx.contextlevel = :contextcourse
-             LEFT JOIN {user} u ON u.id = :emailuserid AND (
-                    LOWER(u.email) = ep.receiver_email
-                        OR
-                    LOWER(u.email) = ep.business
-                )
-                 WHERE ctx.id {$contextsql}
-                       AND (ep.userid = :userid
-                        OR u.id IS NOT NULL)
+                  JOIN {user} u ON u.id = ep.userid OR LOWER(u.email) = ep.receiver_email OR LOWER(u.email) = ep.business
+                 WHERE ctx.id {$contextsql} AND u.id = :userid
               ORDER BY e.courseid";
 
         $params = [
@@ -283,4 +302,37 @@ class provider implements
         $params = $inparams + ['receiver_email' => \core_text::strtolower($user->email)];
         $DB->set_field_select('enrol_paypal', 'receiver_email', '', $select, $params);
     }
+
+    /**
+     * 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->contextlevel != CONTEXT_COURSE) {
+            return;
+        }
+
+        $userids = $userlist->get_userids();
+
+        list($usersql, $userparams) = $DB->get_in_or_equal($userids, SQL_PARAMS_NAMED);
+
+        $params = ['courseid' => $context->instanceid] + $userparams;
+
+        $select = "courseid = :courseid AND userid $usersql";
+        $DB->delete_records_select('enrol_paypal', $select, $params);
+
+        // We do not want to delete the payment record when the user is just the receiver of payment.
+        // In that case, we just delete the receiver's info from the transaction record.
+
+        $select = "courseid = :courseid AND business IN (SELECT LOWER(email) FROM {user} WHERE id $usersql)";
+        $DB->set_field_select('enrol_paypal', 'business', '', $select, $params);
+
+        $select = "courseid = :courseid AND receiver_email IN (SELECT LOWER(email) FROM {user} WHERE id $usersql)";
+        $DB->set_field_select('enrol_paypal', 'receiver_email', '', $select, $params);
+    }
 }
index 2072065..f107787 100644 (file)
@@ -185,7 +185,7 @@ class enrol_paypal_privacy_provider_testcase extends \core_privacy\tests\provide
         );
 
         // Enrol student3 in course3 with businessuser3 as the receiver.
-        $paypalplugin->enrol_user($enrolinstance1, $this->student1->id, $studentrole->id);
+        $paypalplugin->enrol_user($enrolinstance1, $this->student3->id, $studentrole->id);
         $this->create_enrol_paypal_record(
             $this->businessuser3,
             $this->receiveruser3,
@@ -663,4 +663,184 @@ class enrol_paypal_privacy_provider_testcase extends \core_privacy\tests\provide
         ];
         $DB->insert_record('enrol_paypal', $paypaldata);
     }
+
+    /**
+     * Test for provider::get_users_in_context().
+     */
+    public function test_get_users_in_context() {
+        $coursecontext1 = context_course::instance($this->course1->id);
+        $coursecontext2 = context_course::instance($this->course2->id);
+        $coursecontext3 = context_course::instance($this->course3->id);
+
+        $userlist1 = new \core_privacy\local\request\userlist($coursecontext1, 'enrol_paypal');
+        provider::get_users_in_context($userlist1);
+        $this->assertEquals(
+                [
+                    $this->businessuser1->id,
+                    $this->businessuser2->id,
+                    $this->receiveruser1->id,
+                    $this->student1->id,
+                    $this->student12->id
+                ],
+                $userlist1->get_userids(),
+                '', 0.0, 10, true
+        );
+
+        $userlist2 = new \core_privacy\local\request\userlist($coursecontext2, 'enrol_paypal');
+        provider::get_users_in_context($userlist2);
+        $this->assertEquals(
+                [
+                    $this->businessuser1->id,
+                    $this->businessuser2->id,
+                    $this->receiveruser2->id,
+                    $this->student2->id,
+                    $this->student12->id
+                ],
+                $userlist2->get_userids(),
+                '', 0.0, 10, true
+        );
+
+        $userlist3 = new \core_privacy\local\request\userlist($coursecontext3, 'enrol_paypal');
+        provider::get_users_in_context($userlist3);
+        $this->assertEquals(
+                [
+                    $this->businessuser3->id,
+                    $this->receiveruser3->id,
+                    $this->student3->id
+                ],
+                $userlist3->get_userids(),
+                '', 0.0, 10, true
+        );
+    }
+
+    /**
+     * Test for provider::delete_data_for_users().
+     */
+    public function test_delete_data_for_users() {
+        global $DB;
+
+        $coursecontext1 = context_course::instance($this->course1->id);
+
+        // Before deletion, we should have 2 PayPal transactions (1 of them for student12) in course1
+        // and 3 PayPal transactions (1 of them for student12) in course2.
+        // Student12 is enrolled in course1 and course2.
+        // There is 1 transaction in course1 and 2 transactions in course2 under the name of businessuser1.
+        $this->assertEquals(
+                2,
+                $DB->count_records('enrol_paypal', ['courseid' => $this->course1->id])
+        );
+        $this->assertEquals(
+                [$this->course1->id, $this->course2->id],
+                $DB->get_fieldset_select('enrol_paypal', 'courseid', 'userid = ?', [$this->student12->id]),
+                '', 0.0, 10, true
+        );
+        $this->assertEquals(
+                [$this->course1->id, $this->course2->id, $this->course2->id],
+                $DB->get_fieldset_select('enrol_paypal', 'courseid', 'business = ?',
+                        [\core_text::strtolower($this->businessuser1->email)]),
+                '', 0.0, 10, true
+        );
+        $this->assertEquals(
+                3,
+                $DB->count_records('enrol_paypal', ['courseid' => $this->course2->id])
+        );
+
+        // Delete data of student12 and businessuser1 in course1.
+        $approveduserlist = new \core_privacy\local\request\approved_userlist($coursecontext1, 'enrol_paypal',
+                [$this->student12->id, $this->businessuser1->id]);
+        provider::delete_data_for_users($approveduserlist);
+
+        // After deletion, PayPal transactions for student12 in course1 should have been deleted.
+        // Now, we should have 1 PayPal transaction (which is not related to student12) in course1.
+        // There should still be 3 PayPal transactions (1 of them for student12) in course2.
+        // Student12 is not enrolled in course1 anymore, but s/he is still enrolled in course2.
+        // There is no transaction in course1 under the name of businessuser1, but the 2 transactions in course2
+        // that were under his/her name are intact.
+        $this->assertEquals(
+                1,
+                $DB->count_records('enrol_paypal', ['courseid' => $this->course1->id])
+        );
+        $this->assertEquals(
+                [$this->course2->id],
+                $DB->get_fieldset_select('enrol_paypal', 'courseid', 'userid = ?', [$this->student12->id]),
+                '', 0.0, 10, true
+        );
+        $this->assertEquals(
+                [$this->course2->id, $this->course2->id],
+                $DB->get_fieldset_select('enrol_paypal', 'courseid', 'business = ?',
+                        [\core_text::strtolower($this->businessuser1->email)]),
+                '', 0.0, 10, true
+        );
+        $this->assertEquals(
+                3,
+                $DB->count_records('enrol_paypal', ['courseid' => $this->course2->id])
+        );
+    }
+
+    /**
+     * Test for provider::delete_data_for_users() for business user deletion.
+     */
+    public function test_delete_data_for_users_business() {
+        global $DB;
+
+        $coursecontext1 = context_course::instance($this->course1->id);
+
+        // Before deletion, there are 3 transactions under the name of businessuser1 and one of them is in course1.
+        $this->assertEquals(3, $DB->count_records('enrol_paypal', ['business' => $this->businessuser1->email]));
+        $transactions = $DB->get_records('enrol_paypal', [
+            'courseid' => $this->course1->id,
+            'business' => $this->businessuser1->email
+        ]);
+        $this->assertCount(1, $transactions);
+        $transaction = reset($transactions);
+
+        // Delete data of businessuser1 in course1.
+        $approveduserlist = new \core_privacy\local\request\approved_userlist($coursecontext1, 'enrol_paypal',
+                [$this->businessuser1->id]);
+        provider::delete_data_for_users($approveduserlist);
+
+        // After deletion, there should be 2 transactions under the name of businessuser1 and none of them should be in course1.
+        $this->assertEquals(2, $DB->count_records('enrol_paypal', ['business' => $this->businessuser1->email]));
+        $this->assertEquals(0, $DB->count_records('enrol_paypal', [
+            'courseid' => $this->course1->id,
+            'business' => $this->businessuser1->email
+        ]));
+
+        // Also, the transaction in course1 that was under the name of businessuser1 should still exist,
+        // but it should not be under the name of businessuser1 anymore.
+        $newtransaction = $DB->get_record('enrol_paypal', ['id' => $transaction->id]);
+        $this->assertEquals('', $newtransaction->business);
+    }
+
+    /**
+     * Test for provider::delete_data_for_users() for receiver user deletion.
+     */
+    public function test_delete_data_for_users_receiver() {
+        global $DB;
+
+        $coursecontext1 = context_course::instance($this->course1->id);
+
+        // Before deletion, there are 2 transactions under the name of receiveruser1 and both of them are in course1.
+        $this->assertEquals(2, $DB->count_records('enrol_paypal', ['receiver_email' => $this->receiveruser1->email]));
+        $transactions = $DB->get_records('enrol_paypal', [
+            'courseid' => $this->course1->id,
+            'receiver_email' => $this->receiveruser1->email
+        ]);
+        $this->assertCount(2, $transactions);
+
+        // Delete data of receiveruser1 in course1.
+        $approveduserlist = new \core_privacy\local\request\approved_userlist($coursecontext1, 'enrol_paypal',
+                [$this->receiveruser1->id]);
+        provider::delete_data_for_users($approveduserlist);
+
+        // After deletion, there should be no transaction under the name of receiveruser1.
+        $this->assertEquals(0, $DB->count_records('enrol_paypal', ['receiver_email' => $this->receiveruser1->email]));
+
+        // Also, the transactions in course1 that were under the name of receiveruser1 should still exist,
+        // but they should not be under the name of receiveruser1 anymore.
+        foreach ($transactions as $transaction) {
+            $newtransaction = $DB->get_record('enrol_paypal', ['id' => $transaction->id]);
+            $this->assertEquals('', $newtransaction->receiver_email);
+        }
+    }
 }
index 24ce67a..95b6267 100644 (file)
@@ -41,6 +41,8 @@ use \core_privacy\manager;
  */
 class provider implements
     \core_privacy\local\metadata\provider,
+    \core_privacy\local\request\plugin\provider,
+    \core_privacy\local\request\core_userlist_provider,
     \core_privacy\local\request\subsystem\provider {
 
     /**
@@ -107,6 +109,34 @@ class provider implements
         return $contextlist;
     }
 
+    /**
+     * Get the list of contexts that contain user information for the specified user.
+     *
+     * @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(\core_privacy\local\request\userlist $userlist) {
+        $context = $userlist->get_context();
+        if ($context->contextlevel != CONTEXT_MODULE) {
+            return;
+        }
+
+        $params = ['contextid' => $context->id];
+
+        $sql = "SELECT d.usercreated, d.usermodified
+                  FROM {grading_definitions} d
+                  JOIN {grading_areas} a ON a.id = d.areaid
+                  WHERE a.contextid = :contextid";
+        $userlist->add_from_sql('usercreated', $sql, $params);
+        $userlist->add_from_sql('usermodified', $sql, $params);
+
+        $sql = "SELECT i.raterid
+                  FROM {grading_definitions} d
+                  JOIN {grading_areas} a ON a.id = d.areaid
+                  JOIN {grading_instances} i ON i.definitionid = d.id
+                  WHERE a.contextid = :contextid";
+        $userlist->add_from_sql('raterid', $sql, $params);
+    }
+
     /**
      * Export all user data for the specified user, in the specified contexts.
      *
@@ -171,12 +201,27 @@ class provider implements
      * @param  int|null $itemid  An optional item ID to refine the deletion.
      */
     public static function delete_instance_data(\context $context, int $itemid = null) {
+        if (is_null($itemid)) {
+            self::delete_data_for_instances($context);
+        } else {
+            self::delete_data_for_instances($context, [$itemid]);
+        }
+    }
+
+    /**
+     * Deletes all user data related to a context and possibly itemids.
+     *
+     * @param  \context $context The context to delete on.
+     * @param  array $itemids  An optional list of item IDs to refine the deletion.
+     */
+    public static function delete_data_for_instances(\context $context, array $itemids = []) {
         global $DB;
         $itemsql = '';
         $params = ['contextid' => $context->id];
-        if (isset($itemid)) {
-            $params['itemid'] = $itemid;
-            $itemsql = 'AND gi.itemid = :itemid';
+        if (!empty($itemids)) {
+            list($itemsql, $itemparams) = $DB->get_in_or_equal($itemids, SQL_PARAMS_NAMED);
+            $params = array_merge($params, $itemparams);
+            $itemsql = "AND itemid $itemsql";
         }
         $sql = "SELECT gi.id AS instanceid, gd.id, gd.method
                   FROM {grading_definitions} gd
@@ -359,4 +404,13 @@ class provider implements
         );
         // End of section to be removed for final deprecation.
     }
+
+    /**
+     * 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(\core_privacy\local\request\approved_userlist $userlist) {
+        // The only information left to be deleted here is the grading definitions. Currently we are not deleting these.
+    }
 }
index d2e118c..a2122eb 100644 (file)
@@ -106,28 +106,13 @@ class gradingform_guide_privacy_testcase extends provider_testcase {
         );
         $guide->create_guide();
 
-        $controller = $guide->manager->get_controller('guide');
         // In the situation of mod_assign this would be the id from assign_grades.
         $itemid = 1;
-        $instance = $controller->create_instance($user->id, $itemid);
-        // I need the ids for the criteria and there doesn't seem to be a nice method to get it.
-        $criteria = $DB->get_records('gradingform_guide_criteria');
-        $data = ['criteria' => []];
-        foreach ($criteria as $key => $value) {
-            if ($value->shortname == 'Spelling mistakes') {
-                $data['criteria'][$key]['remark'] = 'This user made several mistakes.';
-                $data['criteria'][$key]['remarkformat'] = 0;
-                $data['criteria'][$key]['score'] = 5;
-            } else {
-                $data['criteria'][$key]['remark'] = 'This user has two pictures.';
-                $data['criteria'][$key]['remarkformat'] = 0;
-                $data['criteria'][$key]['score'] = 10;
-            }
-        }
-        $data['itemid'] = $itemid;
-
-        // Update this instance with data.
-        $instance->update($data);
+        $gradedata = [
+            ['remark' => 'This user made several mistakes.', 'score' => 5],
+            ['remark' => 'This user has two pictures.', 'score' => 10]
+        ];
+        $instance = $guide->grade_item($user->id, $itemid, $gradedata);
         $instanceid = $instance->get_data('id');
 
         // Let's try the method we are testing.
index 027aeb0..d3b31ab 100644 (file)
@@ -45,6 +45,8 @@ class test_guide {
     protected $criterionid = 0;
     /** @var integer $sortorder The current id for the sort order. */
     protected $sortorder = 0;
+    /** @var gradingform_controller The grading form controller. */
+    protected $controller;
 
     /** @var grading_manager $manager The grading manager to handle creating the real marking guide. */
     public $manager;
@@ -92,8 +94,8 @@ class test_guide {
             'status' => 20
         ];
 
-        $controller = $this->manager->get_controller('guide');
-        $controller->update_definition($data);
+        $this->controller = $this->manager->get_controller('guide');
+        $this->controller->update_definition($data);
     }
 
     /**
@@ -115,4 +117,42 @@ class test_guide {
             'maxscore' => $maxscore
         ];
     }
+
+    /**
+     * Update the grade for the item provided.
+     * Keep the gradeinfo array in the same order as the definition of the criteria.
+     * The array should be [['remark' => remark, 'score' => intvalue],['remark' => remark, 'score' => intvalue]]
+     * for a guide that has two criteria.
+     *
+     * @param  int $userid The user we are updating.
+     * @param  int $itemid The itemid that the grade will be for
+     * @param  array $gradeinfo Comments and grades for the grade.
+     * @return gradingform_guide_instance The created instance associated with the grade created.
+     */
+    public function grade_item(int $userid, int $itemid, array $gradeinfo) : gradingform_guide_instance {
+        global $DB;
+
+        if (!isset($this->controller)) {
+            throw new Exception("Please call create_guide before calling this method", 1);
+        }
+
+        $instance = $this->controller->create_instance($userid, $itemid);
+
+        // I need the ids for the criteria and there doesn't seem to be a nice method to get it.
+        $criteria = $DB->get_records('gradingform_guide_criteria');
+        $data = ['criteria' => []];
+        $i = 0;
+        // The sort order should keep everything here in order.
+        foreach ($criteria as $key => $value) {
+            $data['criteria'][$key]['remark'] = $gradeinfo[$i]['remark'];
+            $data['criteria'][$key]['remarkformat'] = 0;
+            $data['criteria'][$key]['score'] = $gradeinfo[$i]['score'];
+            $i++;
+        }
+        $data['itemid'] = $itemid;
+
+        // Update this instance with data.
+        $instance->update($data);
+        return $instance;
+    }
 }
index 864da6b..d2a0feb 100644 (file)
@@ -87,6 +87,19 @@ class core_grading_privacy_testcase extends provider_testcase {
         $this->assertCount(0, $contextlist);
     }
 
+    /**
+     * Test retrieval of user ids in a given context.
+     */
+    public function test_get_users_in_context() {
+        $this->resetAfterTest();
+        $this->grading_setup_test_scenario_data();
+        // Instance two has one user who created the definitions and another who modified it.
+        $userlist = new \core_privacy\local\request\userlist($this->instancecontext2, 'core_grading');
+        provider::get_users_in_context($userlist);
+        // Check that we get both.
+        $this->assertCount(2, $userlist->get_userids());
+    }
+
     /**
      * Export for a user with no grading definitions created or modified will not have any data exported.
      */
@@ -274,32 +287,16 @@ class core_grading_privacy_testcase extends provider_testcase {
         );
         $guide->create_guide();
 
-        $controller = $guide->manager->get_controller('guide');
         // In the situation of mod_assign this would be the id from assign_grades.
         $itemid = 1;
-        $instance = $controller->create_instance($user->id, $itemid);
-        // I need the ids for the criteria and there doesn't seem to be a nice method to get it.
-        $criteria = $DB->get_records('gradingform_guide_criteria');
-        $data = ['criteria' => []];
-        foreach ($criteria as $key => $value) {
-            if ($value->shortname == 'Spelling mistakes') {
-                $data['criteria'][$key]['remark'] = 'This user made several mistakes.';
-                $data['criteria'][$key]['remarkformat'] = 0;
-                $data['criteria'][$key]['score'] = 5;
-            } else {
-                $data['criteria'][$key]['remark'] = 'This user has two pictures.';
-                $data['criteria'][$key]['remarkformat'] = 0;
-                $data['criteria'][$key]['score'] = 10;
-            }
-        }
-        $data['itemid'] = $itemid;
-
-        // Update this instance with data.
-        $instance->update($data);
-        $instanceid = $instance->get_data('id');
+        $gradedata = [
+            ['remark' => 'This user made several mistakes.', 'score' => 5],
+            ['remark' => 'This user has two pictures.', 'score' => 10]
+        ];
+        $instance = $guide->grade_item($user->id, $itemid, $gradedata);
 
         provider::export_item_data($modulecontext, $itemid, ['Test']);
-        $data = (array) writer::with_context($modulecontext)->get_data(['Test', 'Marking guide', $instanceid]);
+        $data = (array) writer::with_context($modulecontext)->get_data(['Test', 'Marking guide', $instance->get_data('id')]);
         $this->assertCount(2, $data);
         $this->assertEquals('This user made several mistakes.', $data['Spelling mistakes']->remark);
         $this->assertEquals(5, $data['Spelling mistakes']->score);
@@ -335,49 +332,20 @@ class core_grading_privacy_testcase extends provider_testcase {
         );
         $guide->create_guide();
 
-        $controller = $guide->manager->get_controller('guide');
         // In the situation of mod_assign this would be the id from assign_grades.
         $itemid = 1;
-        $instance = $controller->create_instance($user->id, $itemid);
-        // I need the ids for the criteria and there doesn't seem to be a nice method to get it.
-        $criteria = $DB->get_records('gradingform_guide_criteria');
-        $data = ['criteria' => []];
-        foreach ($criteria as $key => $value) {
-            if ($value->shortname == 'Spelling mistakes') {
-                $data['criteria'][$key]['remark'] = 'This user made several mistakes.';
-                $data['criteria'][$key]['remarkformat'] = 0;
-                $data['criteria'][$key]['score'] = 5;
-            } else {
-                $data['criteria'][$key]['remark'] = 'This user has two pictures.';
-                $data['criteria'][$key]['remarkformat'] = 0;
-                $data['criteria'][$key]['score'] = 10;
-            }
-        }
-        $data['itemid'] = $itemid;
-
-        // Update this instance with data.
-        $instance->update($data);
+        $gradedata = [
+            ['remark' => 'This user made several mistakes.', 'score' => 5],
+            ['remark' => 'This user has two pictures.', 'score' => 10]
+        ];
+        $instance = $guide->grade_item($user->id, $itemid, $gradedata);
 
         $itemid = 2;
-        $instance = $controller->create_instance($user->id, $itemid);
-        // I need the ids for the criteria and there doesn't seem to be a nice method to get it.
-        $criteria = $DB->get_records('gradingform_guide_criteria');
-        $data = ['criteria' => []];
-        foreach ($criteria as $key => $value) {
-            if ($value->shortname == 'Spelling mistakes') {
-                $data['criteria'][$key]['remark'] = 'This user made no mistakes.';
-                $data['criteria'][$key]['remarkformat'] = 0;
-                $data['criteria'][$key]['score'] = 25;
-            } else {
-                $data['criteria'][$key]['remark'] = 'This user has one pictures.';
-                $data['criteria'][$key]['remarkformat'] = 0;
-                $data['criteria'][$key]['score'] = 5;
-            }
-        }
-        $data['itemid'] = $itemid;
-
-        // Update this instance with data.
-        $instance->update($data);
+        $gradedata = [
+            ['remark' => 'This user made no mistakes.', 'score' => 25],
+            ['remark' => 'This user has one picture.', 'score' => 5]
+        ];
+        $instance = $guide->grade_item($user->id, $itemid, $gradedata);
 
         // Check how many records we have in the fillings table.
         $records = $DB->get_records('gradingform_guide_fillings');
@@ -395,6 +363,72 @@ class core_grading_privacy_testcase extends provider_testcase {
         $this->assertEmpty($records);
     }
 
+    /**
+     * Test the deletion of multiple instances at once.
+     */
+    public function test_delete_data_for_instances() {
+        global $DB;
+        $this->resetAfterTest();
+        $course = $this->getDataGenerator()->create_course();
+        $module = $this->getDataGenerator()->create_module('assign', ['course' => $course]);
+        $user1 = $this->getDataGenerator()->create_user();
+        $user2 = $this->getDataGenerator()->create_user();
+        $user3 = $this->getDataGenerator()->create_user();
+
+        $this->setUser($user1);
+
+        $modulecontext = context_module::instance($module->cmid);
+        $guide = new test_guide($modulecontext, 'testrubrib', 'Description text');
+        $guide->add_criteria(
+            'Spelling mistakes',
+            'Full marks will be given for no spelling mistakes.',
+            'Deduct 5 points per spelling mistake made.',
+            25
+        );
+        $guide->add_criteria(
+            'Pictures',
+            'Full marks will be given for including 3 pictures.',
+            'Give 5 points for each picture present',
+            15
+        );
+        $guide->create_guide();
+
+        // In the situation of mod_assign this would be the id from assign_grades.
+        $itemid1 = 1;
+        $gradedata = [
+            ['remark' => 'This user made several mistakes.', 'score' => 5],
+            ['remark' => 'This user has two pictures.', 'score' => 10]
+        ];
+        $instance1 = $guide->grade_item($user1->id, $itemid1, $gradedata);
+
+        $itemid2 = 2;
+        $gradedata = [
+            ['remark' => 'This user made a couple of mistakes.', 'score' => 15],
+            ['remark' => 'This user has one picture.', 'score' => 10]
+        ];
+        $instance2 = $guide->grade_item($user2->id, $itemid2, $gradedata);
+
+        $itemid3 = 3;
+        $gradedata = [
+            ['remark' => 'This user made one mistakes.', 'score' => 20],
+            ['remark' => 'This user has one picture.', 'score' => 10]
+        ];
+        $instance3 = $guide->grade_item($user3->id, $itemid3, $gradedata);
+
+        $records = $DB->get_records('gradingform_guide_fillings');
+        $this->assertCount(6, $records);
+
+        // Delete all user data for items 1 and 3.
+        provider::delete_data_for_instances($modulecontext, [$itemid1, $itemid3]);
+        $records = $DB->get_records('gradingform_guide_fillings');
+        $this->assertCount(2, $records);
+        $instanceid = $instance2->get_data('id');
+        // The instance id should match for all remaining records.
+        foreach ($records as $record) {
+            $this->assertEquals($instanceid, $record->instanceid);
+        }
+    }
+
     /**
      * Helper function to setup the environment.
      *
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 c935972..4bb664d 100644 (file)
@@ -48,6 +48,15 @@ class assign_plugin_request_data {
     /** @var object If set then only export data related directly to this user. */
     protected $user;
 
+    /** @var array The user IDs of the users that will be affected. */
+    protected $userids;
+
+    /** @var array The submissions related to the users added. */
+    protected $submissions = [];
+
+    /** @var array The grades related to the users added. */
+    protected $grades = [];
+
     /** @var assign The assign object */
     protected $assign;
 
@@ -69,6 +78,16 @@ class assign_plugin_request_data {
         $this->assign = $assign;
     }
 
+    /**
+     * Method for adding an array of user IDs. This will do a query to populate the submissions and grades
+     * for these users.
+     *
+     * @param array $userids User IDs to do something with.
+     */
+    public function set_userids(array $userids) {
+        $this->userids = $userids;
+    }
+
     /**
      * Getter for this attribute.
      *
@@ -113,4 +132,75 @@ class assign_plugin_request_data {
     public function get_assign() {
         return $this->assign;
     }
+
+    /**
+     * A method to conveniently fetch the assign id.
+     *
+     * @return int The assign id.
+     */
+    public function get_assignid() {
+        return $this->assign->get_instance()->id;
+    }
+
+    /**
+     * Get all of the user IDs
+     *
+     * @return array User IDs
+     */
+    public function get_userids() {
+        return $this->userids;
+    }
+
+    /**
+     * Returns all of the submission IDs
+     *
+     * @return array submission IDs
+     */
+    public function get_submissionids() {
+        return array_keys($this->submissions);
+    }
+
+    /**
+     * Returns the submissions related to the user IDs
+     *
+     * @return array User submissions.
+     */
+    public function get_submissions() {
+        return $this->submissions;
+    }
+
+    /**
+     * Returns the grade IDs related to the user IDs
+     *
+     * @return array User grade IDs.
+     */
+    public function get_gradeids() {
+        return array_keys($this->grades);
+    }
+
+    /**
+     * Returns the grades related to the user IDs
+     *
+     * @return array User grades.
+     */
+    public function get_grades() {
+        return $this->grades;
+    }
+
+    /**
+     * Fetches all of the submissions and grades related to the User IDs provided. Use get_grades, get_submissions etc to
+     * retrieve this information.
+     */
+    public function populate_submissions_and_grades() {
+        global $DB;
+
+        if (empty($this->get_userids())) {
+            throw new \coding_exception('Please use set_userids() before calling this method.');
+        }
+
+        list($sql, $params) = $DB->get_in_or_equal($this->get_userids(), SQL_PARAMS_NAMED);
+        $params['assign'] = $this->get_assign()->get_instance()->id;
+        $this->submissions = $DB->get_records_select('assign_submission', "assignment = :assign AND userid $sql", $params);
+        $this->grades = $DB->get_records_select('assign_grades', "assignment = :assign AND userid $sql", $params);
+    }
 }
diff --git a/mod/assign/classes/privacy/assignfeedback_user_provider.php b/mod/assign/classes/privacy/assignfeedback_user_provider.php
new file mode 100644 (file)
index 0000000..9048430
--- /dev/null
@@ -0,0 +1,52 @@
+<?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/>.
+
+/**
+ * This file contains the assignfeedback_user_provider interface.
+ *
+ * Assignment Sub plugins should implement this if they store personal information and can retrieve a userid.
+ *
+ * @package mod_assign
+ * @copyright 2018 Adrian Greeve <adrian@moodle.com>
+ *
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+namespace mod_assign\privacy;
+
+defined('MOODLE_INTERNAL') || die();
+
+interface assignfeedback_user_provider extends \core_privacy\local\request\plugin\subplugin_provider {
+
+    /**
+     * If you have tables that contain userids and you can generate entries in your tables without creating an
+     * entry in the assign_grades table then please fill in this method.
+     *
+     * @param  \core_privacy\local\request\userlist $userlist The userlist object
+     */
+    public static function get_userids_from_context(\core_privacy\local\request\userlist $userlist);
+
+    /**
+     * Deletes all feedback for the grade ids / userids provided in a context.
+     * assign_plugin_request_data contains:
+     * - context
+     * - assign object
+     * - grade ids (pluginids)
+     * - user ids
+     * @param  assign_plugin_request_data $deletedata A class that contains the relevant information required for deletion.
+     */
+    public static function delete_feedback_for_grades(assign_plugin_request_data $deletedata);
+
+}
\ No newline at end of file
diff --git a/mod/assign/classes/privacy/assignsubmission_user_provider.php b/mod/assign/classes/privacy/assignsubmission_user_provider.php
new file mode 100644 (file)
index 0000000..1a9331c
--- /dev/null
@@ -0,0 +1,54 @@
+<?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/>.
+
+/**
+ * This file contains the assignsubmission_user_provider interface.
+ *
+ * Assignment Sub plugins should implement this if they store personal information and can retrieve a userid.
+ *
+ * @package mod_assign
+ * @copyright 2018 Adrian Greeve <adrian@moodle.com>
+ *
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+namespace mod_assign\privacy;
+
+use core_privacy\local\request\userlist;
+
+defined('MOODLE_INTERNAL') || die();
+
+interface assignsubmission_user_provider extends \core_privacy\local\request\plugin\subplugin_provider {
+
+    /**
+     * If you have tables that contain userids and you can generate entries in your tables without creating an
+     * entry in the assign_submission table then please fill in this method.
+     *
+     * @param  userlist $userlist The userlist object
+     */
+    public static function get_userids_from_context(userlist $userlist);
+
+    /**
+     * Deletes all submissions for the submission ids / userids provided in a context.
+     * assign_plugin_request_data contains:
+     * - context
+     * - assign object
+     * - submission ids (pluginids)
+     * - user ids
+     * @param  assign_plugin_request_data $deletedata A class that contains the relevant information required for deletion.
+     */
+    public static function delete_submissions(assign_plugin_request_data $deletedata);
+
+}
\ No newline at end of file
index 56a7736..fc0e0ab 100644 (file)
@@ -29,14 +29,13 @@ defined('MOODLE_INTERNAL') || die();
 require_once($CFG->dirroot . '/mod/assign/locallib.php');
 
 use \core_privacy\local\metadata\collection;
-use \core_privacy\local\metadata\provider as metadataprovider;
 use \core_privacy\local\request\contextlist;
-use \core_privacy\local\request\plugin\provider as pluginprovider;
-use \core_privacy\local\request\user_preference_provider as preference_provider;
 use \core_privacy\local\request\writer;
 use \core_privacy\local\request\approved_contextlist;
 use \core_privacy\local\request\transform;
 use \core_privacy\local\request\helper;
+use \core_privacy\local\request\userlist;
+use \core_privacy\local\request\approved_userlist;
 use \core_privacy\manager;
 
 /**
@@ -46,11 +45,21 @@ use \core_privacy\manager;
  * @copyright  2018 Adrian Greeve <adrian@moodle.com>
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
-class provider implements metadataprovider, pluginprovider, preference_provider {
+class provider implements
+        \core_privacy\local\metadata\provider,
+        \core_privacy\local\request\plugin\provider,
+        \core_privacy\local\request\user_preference_provider,
+        \core_privacy\local\request\core_userlist_provider {
 
     /** Interface for all assign submission sub-plugins. */
     const ASSIGNSUBMISSION_INTERFACE = 'mod_assign\privacy\assignsubmission_provider';
 
+    /** Interface for all assign submission sub-plugins. This allows for deletion of users with a context. */
+    const ASSIGNSUBMISSION_USER_INTERFACE = 'mod_assign\privacy\assignsubmission_user_provider';
+
+    /** Interface for all assign feedback sub-plugins. This allows for deletion of users with a context. */
+    const ASSIGNFEEDBACK_USER_INTERFACE = 'mod_assign\privacy\assignfeedback_user_provider';
+
     /** Interface for all assign feedback sub-plugins. */
     const ASSIGNFEEDBACK_INTERFACE = 'mod_assign\privacy\assignfeedback_provider';
 
@@ -192,6 +201,76 @@ class provider implements metadataprovider, pluginprovider, preference_provider
         return $contextlist;
     }
 
+    /**
+     * Get the list of contexts that contain user information for the specified user.
+     *
+     * @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->contextlevel != CONTEXT_MODULE) {
+            return;
+        }
+
+        $params = [
+            'modulename' => 'assign',
+            'contextid' => $context->id,
+            'contextlevel' => CONTEXT_MODULE
+        ];
+
+        $sql = "SELECT g.userid, g.grader
+                  FROM {context} ctx
+                  JOIN {course_modules} cm ON cm.id = ctx.instanceid
+                  JOIN {modules} m ON m.id = cm.module AND m.name = :modulename
+                  JOIN {assign} a ON a.id = cm.instance
+                  JOIN {assign_grades} g ON a.id = g.assignment
+                 WHERE ctx.id = :contextid AND ctx.contextlevel = :contextlevel";
+        $userlist->add_from_sql('userid', $sql, $params);
+        $userlist->add_from_sql('grader', $sql, $params);
+
+        $sql = "SELECT o.userid
+                  FROM {context} ctx
+                  JOIN {course_modules} cm ON cm.id = ctx.instanceid
+                  JOIN {modules} m ON m.id = cm.module AND m.name = :modulename
+                  JOIN {assign} a ON a.id = cm.instance
+                  JOIN {assign_overrides} o ON a.id = o.assignid
+                 WHERE ctx.id = :contextid AND ctx.contextlevel = :contextlevel";
+        $userlist->add_from_sql('userid', $sql, $params);
+
+        $sql = "SELECT s.userid
+                  FROM {context} ctx
+                  JOIN {course_modules} cm ON cm.id = ctx.instanceid
+                  JOIN {modules} m ON m.id = cm.module AND m.name = :modulename
+                  JOIN {assign} a ON a.id = cm.instance
+                  JOIN {assign_submission} s ON a.id = s.assignment
+                 WHERE ctx.id = :contextid AND ctx.contextlevel = :contextlevel";
+        $userlist->add_from_sql('userid', $sql, $params);
+
+        $sql = "SELECT uf.userid
+                  FROM {context} ctx
+                  JOIN {course_modules} cm ON cm.id = ctx.instanceid
+                  JOIN {modules} m ON m.id = cm.module AND m.name = :modulename
+                  JOIN {assign} a ON a.id = cm.instance
+                  JOIN {assign_user_flags} uf ON a.id = uf.assignment
+                 WHERE ctx.id = :contextid AND ctx.contextlevel = :contextlevel";
+        $userlist->add_from_sql('userid', $sql, $params);
+
+        $sql = "SELECT um.userid
+                  FROM {context} ctx
+                  JOIN {course_modules} cm ON cm.id = ctx.instanceid
+                  JOIN {modules} m ON m.id = cm.module AND m.name = :modulename
+                  JOIN {assign} a ON a.id = cm.instance
+                  JOIN {assign_user_mapping} um ON a.id = um.assignment
+                 WHERE ctx.id = :contextid AND ctx.contextlevel = :contextlevel";
+        $userlist->add_from_sql('userid', $sql, $params);
+
+        manager::plugintype_class_callback('assignsubmission', self::ASSIGNSUBMISSION_USER_INTERFACE,
+                'get_userids_from_context', [$userlist]);
+        manager::plugintype_class_callback('assignfeedback', self::ASSIGNFEEDBACK_USER_INTERFACE,
+                'get_userids_from_context', [$userlist]);
+    }
+
     /**
      * Write out the user data filtered by contexts.
      *
@@ -265,7 +344,7 @@ class provider implements metadataprovider, pluginprovider, preference_provider
                 }
 
                 // Time to roll my own method for deleting overrides.
-                static::delete_user_overrides($assign);
+                static::delete_overrides_for_users($assign);
                 $DB->delete_records('assign_submission', ['assignment' => $assign->get_instance()->id]);
                 $DB->delete_records('assign_user_flags', ['assignment' => $assign->get_instance()->id]);
                 $DB->delete_records('assign_user_mapping', ['assignment' => $assign->get_instance()->id]);
@@ -311,7 +390,7 @@ class provider implements metadataprovider, pluginprovider, preference_provider
                 }
             }
 
-            static::delete_user_overrides($assign, $user);
+            static::delete_overrides_for_users($assign, [$user->id]);
             $DB->delete_records('assign_user_flags', ['assignment' => $assignid, 'userid' => $user->id]);
             $DB->delete_records('assign_user_mapping', ['assignment' => $assignid, 'userid' => $user->id]);
             $DB->delete_records('assign_grades', ['assignment' => $assignid, 'userid' => $user->id]);
@@ -320,31 +399,86 @@ class provider implements metadataprovider, pluginprovider, preference_provider
     }
 
     /**
-     * Deletes assignment overrides.
+     * Delete multiple users within a single context.
      *
-     * @param  \assign $assign The assignment object
-     * @param  \stdClass $user The user object if we are deleting only the overrides for one user.
+     * @param  approved_userlist $userlist The approved context and user information to delete information for.
      */
-    protected static function delete_user_overrides(\assign $assign, \stdClass $user = null) {
+    public static function delete_data_for_users(approved_userlist $userlist) {
         global $DB;
 
+        $context = $userlist->get_context();
+        if ($context->contextlevel != CONTEXT_MODULE) {
+            return;
+        }
+
+        $userids = $userlist->get_userids();
+
+        $assign = new \assign($context, null, null);
         $assignid = $assign->get_instance()->id;
-        $params = (isset($user)) ? ['assignid' => $assignid, 'userid' => $user->id] : ['assignid' => $assignid];
+        $requestdata = new assign_plugin_request_data($context, $assign);
+        $requestdata->set_userids($userids);
+        $requestdata->populate_submissions_and_grades();
+        manager::plugintype_class_callback('assignsubmission', self::ASSIGNSUBMISSION_USER_INTERFACE, 'delete_submissions',
+                [$requestdata]);
+        manager::plugintype_class_callback('assignfeedback', self::ASSIGNFEEDBACK_USER_INTERFACE, 'delete_feedback_for_grades',
+                [$requestdata]);
+
+        // Update this function to delete advanced grading information.
+        $gradingmanager = get_grading_manager($context, 'mod_assign', 'submissions');
+        $controller = $gradingmanager->get_active_controller();
+        if (isset($controller)) {
+            $gradeids = $requestdata->get_gradeids();
+            // Careful here, if no gradeids are provided then all data is deleted for the context.
+            if (!empty($gradeids)) {
+                \core_grading\privacy\provider::delete_data_for_instances($context, $gradeids);
+            }
+        }
 
-        $overrides = $DB->get_records('assign_overrides', $params);
-        if (!empty($overrides)) {
-            foreach ($overrides as $override) {
+        static::delete_overrides_for_users($assign, $userids);
+        list($sql, $params) = $DB->get_in_or_equal($userids, SQL_PARAMS_NAMED);
+        $params['assignment'] = $assignid;
+        $DB->delete_records_select('assign_user_flags', "assignment = :assignment AND userid $sql", $params);
+        $DB->delete_records_select('assign_user_mapping', "assignment = :assignment AND userid $sql", $params);
+        $DB->delete_records_select('assign_grades', "assignment = :assignment AND userid $sql", $params);
+        $DB->delete_records_select('assign_submission', "assignment = :assignment AND userid $sql", $params);
+    }
 
-                // First delete calendar events associated with this override.
-                $conditions = ['modulename' => 'assign', 'instance' => $assignid];
-                if (isset($user)) {
-                    $conditions['userid'] = $user->id;
-                }
-                $DB->delete_records('event', $conditions);
+    /**
+     * Deletes assignment overrides in bulk
+     *
+     * @param  \assign $assign  The assignment object
+     * @param  array   $userids An array of user IDs
+     */
+    protected static function delete_overrides_for_users(\assign $assign, array $userids = []) {
+        global $DB;
+        $assignid = $assign->get_instance()->id;
 
-                // Next delete the overrides.
-                $DB->delete_records('assign_overrides', ['id' => $override->id]);
+        $usersql = '';
+        $params = ['assignid' => $assignid];
+        if (!empty($userids)) {
+            list($usersql, $userparams) = $DB->get_in_or_equal($userids, SQL_PARAMS_NAMED);
+            $params = array_merge($params, $userparams);
+            $overrides = $DB->get_records_select('assign_overrides', "assignid = :assignid AND userid $usersql", $params);
+        } else {
+            $overrides = $DB->get_records('assign_overrides', $params);
+        }
+        if (!empty($overrides)) {
+            $params = ['modulename' => 'assign', 'instance' => $assignid];
+            if (!empty($userids)) {
+                $params = array_merge($params, $userparams);
+                $DB->delete_records_select('event', "modulename = :modulename AND instance = :instance AND userid $usersql",
+                        $params);
+                // Setting up for the next query.
+                $params = $userparams;
+                $usersql = "AND userid $usersql";
+            } else {
+                $DB->delete_records('event', $params);
+                // Setting up for the next query.
+                $params = [];
             }
+            list($overridesql, $overrideparams) = $DB->get_in_or_equal(array_keys($overrides), SQL_PARAMS_NAMED);
+            $params = array_merge($params, $overrideparams);
+            $DB->delete_records_select('assign_overrides', "id $overridesql $usersql", $params);
         }
     }
 
index 9302526..e6453e6 100644 (file)
@@ -29,8 +29,6 @@ defined('MOODLE_INTERNAL') || die();
 require_once($CFG->dirroot . '/mod/assign/locallib.php');
 
 use \core_privacy\local\metadata\collection;
-use \core_privacy\local\metadata\provider as metadataprovider;
-use \mod_assign\privacy\assignfeedback_provider;
 use \core_privacy\local\request\writer;
 use \core_privacy\local\request\contextlist;
 use \mod_assign\privacy\assign_plugin_request_data;
@@ -43,7 +41,10 @@ use \mod_assign\privacy\useridlist;
  * @copyright  2018 Adrian Greeve <adrian@moodle.com>
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
-class provider implements metadataprovider, assignfeedback_provider {
+class provider implements
+        \core_privacy\local\metadata\provider,
+        \mod_assign\privacy\assignfeedback_provider,
+        \mod_assign\privacy\assignfeedback_user_provider {
 
     /**
      * Return meta data about this plugin.
@@ -83,6 +84,16 @@ class provider implements metadataprovider, assignfeedback_provider {
         // Not required.
     }
 
+    /**
+     * If you have tables that contain userids and you can generate entries in your tables without creating an
+     * entry in the assign_grades table then please fill in this method.
+     *
+     * @param  \core_privacy\local\request\userlist $userlist The userlist object
+     */
+    public static function get_userids_from_context(\core_privacy\local\request\userlist $userlist) {
+        // Not required.
+    }
+
     /**
      * Export all user data for this plugin.
      *
@@ -146,7 +157,37 @@ class provider implements metadataprovider, assignfeedback_provider {
         $fs->delete_area_files($requestdata->get_context()->id, ASSIGNFEEDBACK_COMMENTS_COMPONENT,
             ASSIGNFEEDBACK_COMMENTS_FILEAREA, $requestdata->get_pluginobject()->id);
 
-        $DB->delete_records('assignfeedback_comments', ['assignment' => $requestdata->get_assign()->get_instance()->id,
+        $DB->delete_records('assignfeedback_comments', ['assignment' => $requestdata->get_assignid(),
                 'grade' => $requestdata->get_pluginobject()->id]);
     }
+
+    /**
+     * Deletes all feedback for the grade ids / userids provided in a context.
+     * assign_plugin_request_data contains:
+     * - context
+     * - assign object
+     * - grade ids (pluginids)
+     * - user ids
+     * @param  assign_plugin_request_data $deletedata A class that contains the relevant information required for deletion.
+     */
+    public static function delete_feedback_for_grades(assign_plugin_request_data $deletedata) {
+        global $DB;
+        if (empty($deletedata->get_gradeids())) {
+            return;
+        }
+
+        list($sql, $params) = $DB->get_in_or_equal($deletedata->get_gradeids(), SQL_PARAMS_NAMED);
+
+        $fs = new \file_storage();
+        $fs->delete_area_files_select(
+                $deletedata->get_context()->id,
+                ASSIGNFEEDBACK_COMMENTS_COMPONENT,
+                ASSIGNFEEDBACK_COMMENTS_FILEAREA,
+                $sql,
+                $params
+            );
+
+        $params['assignment'] = $deletedata->get_assignid();
+        $DB->delete_records_select('assignfeedback_comments', "assignment = :assignment AND grade $sql", $params);
+    }
 }
index 276b9cf..2cfe8d5 100644 (file)
@@ -267,4 +267,83 @@ class assignfeedback_comments_privacy_testcase extends \mod_assign\tests\mod_ass
         $this->assertEquals('feedback1.txt', $file->get_filename());
         $this->assertEquals($grade2->id, $file->get_itemid());
     }
+
+    /**
+     * Test that a grade item is deleted for a user.
+     */
+    public function test_delete_feedback_for_grades() {
+        $this->resetAfterTest();
+        // Create course, assignment, submission, and then a feedback comment.
+        $course = $this->getDataGenerator()->create_course();
+        // Student.
+        $user1 = $this->getDataGenerator()->create_user();
+        $user2 = $this->getDataGenerator()->create_user();
+        $user3 = $this->getDataGenerator()->create_user();
+        $user4 = $this->getDataGenerator()->create_user();
+        // Teacher.
+        $user5 = $this->getDataGenerator()->create_user();
+        $this->getDataGenerator()->enrol_user($user1->id, $course->id, 'student');
+        $this->getDataGenerator()->enrol_user($user2->id, $course->id, 'student');
+        $this->getDataGenerator()->enrol_user($user3->id, $course->id, 'student');
+        $this->getDataGenerator()->enrol_user($user4->id, $course->id, 'student');
+        $this->getDataGenerator()->enrol_user($user5->id, $course->id, 'editingteacher');
+        $assign1 = $this->create_instance(['course' => $course]);
+        $assign2 = $this->create_instance(['course' => $course]);
+
+        $feedbacktext = '<p>first comment for this test</p>';
+        list($plugin1, $grade1) = $this->create_feedback($assign1, $user1, $user5, 'Submission text', $feedbacktext);
+        $feedbacktext = '<p>Comment for second student.</p>';
+        list($plugin2, $grade2) = $this->create_feedback($assign1, $user2, $user5, 'Submission text', $feedbacktext);
+        $feedbacktext = '<p>Comment for third student.</p>';
+        list($plugin3, $grade3) = $this->create_feedback($assign1, $user3, $user5, 'Submission text', $feedbacktext);
+        $feedbacktext = '<p>Comment for third student in the second assignment.</p>';
+        list($plugin4, $grade4) = $this->create_feedback($assign2, $user3, $user5, 'Submission text', $feedbacktext);
+        $feedbacktext = '<p>Comment for fourth student in the second assignment.</p>';
+        list($plugin5, $grade5) = $this->create_feedback($assign2, $user4, $user5, 'Submission text', $feedbacktext);
+
+        // Check that we have data.
+        $feedbackcomments = $plugin1->get_feedback_comments($grade1->id);
+        $this->assertNotEmpty($feedbackcomments);
+        $feedbackcomments = $plugin2->get_feedback_comments($grade2->id);
+        $this->assertNotEmpty($feedbackcomments);
+        $feedbackcomments = $plugin3->get_feedback_comments($grade3->id);
+        $this->assertNotEmpty($feedbackcomments);
+        $feedbackcomments = $plugin4->get_feedback_comments($grade4->id);
+        $this->assertNotEmpty($feedbackcomments);
+        $feedbackcomments = $plugin5->get_feedback_comments($grade5->id);
+        $this->assertNotEmpty($feedbackcomments);
+
+        $fs = new file_storage();
+        // 6 including directories for assign 1.
+        // 4 including directories for assign 2.
+        $this->assertCount(6, $fs->get_area_files($assign1->get_context()->id,
+                ASSIGNFEEDBACK_COMMENTS_COMPONENT, ASSIGNFEEDBACK_COMMENTS_FILEAREA));
+        $this->assertCount(4, $fs->get_area_files($assign2->get_context()->id,
+                ASSIGNFEEDBACK_COMMENTS_COMPONENT, ASSIGNFEEDBACK_COMMENTS_FILEAREA));
+
+        $deletedata = new \mod_assign\privacy\assign_plugin_request_data($assign1->get_context(), $assign1);
+        $deletedata->set_userids([$user1->id, $user3->id]);
+        $deletedata->populate_submissions_and_grades();
+        assignfeedback_comments\privacy\provider::delete_feedback_for_grades($deletedata);
+
+        // Check that grade 1 and grade 3 have been removed.
+        $feedbackcomments = $plugin1->get_feedback_comments($grade1->id);
+        $this->assertEmpty($feedbackcomments);
+        $feedbackcomments = $plugin2->get_feedback_comments($grade2->id);
+        $this->assertNotEmpty($feedbackcomments);
+        $feedbackcomments = $plugin3->get_feedback_comments($grade3->id);
+        $this->assertEmpty($feedbackcomments);
+        $feedbackcomments = $plugin4->get_feedback_comments($grade4->id);
+        $this->assertNotEmpty($feedbackcomments);
+        $feedbackcomments = $plugin5->get_feedback_comments($grade5->id);
+        $this->assertNotEmpty($feedbackcomments);
+
+        // We have deleted two from assign 1, and none from assign 2.
+        // 2 including directories for assign 1.
+        // 4 including directories for assign 2.
+        $this->assertCount(2, $fs->get_area_files($assign1->get_context()->id,
+                ASSIGNFEEDBACK_COMMENTS_COMPONENT, ASSIGNFEEDBACK_COMMENTS_FILEAREA));
+        $this->assertCount(4, $fs->get_area_files($assign2->get_context()->id,
+                ASSIGNFEEDBACK_COMMENTS_COMPONENT, ASSIGNFEEDBACK_COMMENTS_FILEAREA));
+    }
 }
index 7170604..c3fb4da 100644 (file)
@@ -29,7 +29,6 @@ defined('MOODLE_INTERNAL') || die();
 require_once($CFG->dirroot . '/mod/assign/locallib.php');
 
 use \core_privacy\local\metadata\collection;
-use \core_privacy\local\metadata\provider as metadataprovider;
 use \mod_assign\privacy\assignfeedback_provider;
 use \core_privacy\local\request\writer;
 use \core_privacy\local\request\contextlist;
@@ -43,7 +42,10 @@ use \mod_assign\privacy\useridlist;
  * @copyright  2018 Adrian Greeve <adrian@moodle.com>
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
-class provider implements metadataprovider, assignfeedback_provider {
+class provider implements
+        \core_privacy\local\metadata\provider,
+        \mod_assign\privacy\assignfeedback_provider,
+        \mod_assign\privacy\assignfeedback_user_provider {
 
     /**
      * Return meta data about this plugin.
@@ -83,6 +85,16 @@ class provider implements metadataprovider, assignfeedback_provider {
         // Not required.
     }
 
+    /**
+     * If you have tables that contain userids and you can generate entries in your tables without creating an
+     * entry in the assign_grades table then please fill in this method.
+     *
+     * @param  \core_privacy\local\request\userlist $userlist The userlist object
+     */
+    public static function get_userids_from_context(\core_privacy\local\request\userlist $userlist) {
+        // Not required.
+    }
+
     /**
      * Export all user data for this plugin.
      *
@@ -127,21 +139,41 @@ class provider implements metadataprovider, assignfeedback_provider {
      * @param  assign_plugin_request_data $requestdata Data useful for deleting user data.
      */
     public static function delete_feedback_for_grade(assign_plugin_request_data $requestdata) {
+        $requestdata->set_userids([$requestdata->get_user()->id]);
+        $requestdata->populate_submissions_and_grades();
+        self::delete_feedback_for_grades($requestdata);
+    }
+
+
+    /**
+     * Deletes all feedback for the grade ids / userids provided in a context.
+     * assign_plugin_request_data contains:
+     * - context
+     * - assign object
+     * - grade ids (pluginids)
+     * - user ids
+     * @param  assign_plugin_request_data $deletedata A class that contains the relevant information required for deletion.
+     */
+    public static function delete_feedback_for_grades(assign_plugin_request_data $deletedata) {
         global $DB;
 
-        $assign = $requestdata->get_assign();
+        if (empty($deletedata->get_gradeids())) {
+            return;
+        }
+
+        $assign = $deletedata->get_assign();
         $plugin = $assign->get_plugin_by_type('assignfeedback', 'editpdf');
         $fileareas = $plugin->get_file_areas();
         $fs = get_file_storage();
+        list($sql, $params) = $DB->get_in_or_equal($deletedata->get_gradeids(), SQL_PARAMS_NAMED);
         foreach ($fileareas as $filearea => $notused) {
             // Delete pdf files.
-            $fs->delete_area_files($requestdata->get_context()->id, 'assignfeedback_editpdf',
-                    $filearea, $requestdata->get_pluginobject()->id);
+            $fs->delete_area_files_select($deletedata->get_context()->id, 'assignfeedback_editpdf', $filearea, $sql, $params);
         }
 
         // Remove table entries.
-        $DB->delete_records('assignfeedback_editpdf_annot', ['gradeid' => $requestdata->get_pluginobject()->id]);
-        $DB->delete_records('assignfeedback_editpdf_cmnt', ['gradeid' => $requestdata->get_pluginobject()->id]);
+        $DB->delete_records_select('assignfeedback_editpdf_annot', "gradeid $sql", $params);
+        $DB->delete_records_select('assignfeedback_editpdf_cmnt', "gradeid $sql", $params);
         // Submission records in assignfeedback_editpdf_queue will be cleaned up in a scheduled task
     }
 }
index 2291d6c..af07abe 100644 (file)
@@ -241,4 +241,78 @@ class assignfeedback_editpdf_privacy_testcase extends \mod_assign\tests\mod_assi
         // Check that user 2 data is still there.
         $this->assertFalse($plugin2->is_empty($grade2));
     }
+
+    /**
+     * Test that a grade item is deleted for a user.
+     */
+    public function test_delete_feedback_for_grades() {
+        global $DB;
+
+        $this->resetAfterTest();
+        // Create course, assignment, submission, and then a feedback comment.
+        $course = $this->getDataGenerator()->create_course();
+        // Students.
+        $user1 = $this->getDataGenerator()->create_user();
+        $user2 = $this->getDataGenerator()->create_user();
+        $user3 = $this->getDataGenerator()->create_user();
+        $user4 = $this->getDataGenerator()->create_user();
+        // Teacher.
+        $user5 = $this->getDataGenerator()->create_user();
+        $this->getDataGenerator()->enrol_user($user1->id, $course->id, 'student');
+        $this->getDataGenerator()->enrol_user($user2->id, $course->id, 'student');
+        $this->getDataGenerator()->enrol_user($user3->id, $course->id, 'student');
+        $this->getDataGenerator()->enrol_user($user4->id, $course->id, 'student');
+        $this->getDataGenerator()->enrol_user($user5->id, $course->id, 'editingteacher');
+        $assign1 = $this->create_instance(['course' => $course,
+                'assignsubmission_file_enabled' => 1,
+                'assignsubmission_file_maxfiles' => 1,
+                'assignfeedback_editpdf_enabled' => 1,
+                'assignsubmission_file_maxsizebytes' => 1000000]);
+
+        $assign2 = $this->create_instance(['course' => $course,
+                'assignsubmission_file_enabled' => 1,
+                'assignsubmission_file_maxfiles' => 1,
+                'assignfeedback_editpdf_enabled' => 1,
+                'assignsubmission_file_maxsizebytes' => 1000000]);
+
+        $context = $assign1->get_context();
+
+        list($plugin1, $grade1, $storedfile1) = $this->create_feedback($assign1, $user1, $user5);
+        list($plugin2, $grade2, $storedfile2) = $this->create_feedback($assign1, $user2, $user5);
+        list($plugin3, $grade3, $storedfile3) = $this->create_feedback($assign1, $user3, $user5);
+        list($plugin4, $grade4, $storedfile4) = $this->create_feedback($assign2, $user3, $user5);
+        list($plugin5, $grade5, $storedfile5) = $this->create_feedback($assign2, $user4, $user5);
+
+        // Check that we have data.
+        $this->assertFalse($plugin1->is_empty($grade1));
+        $this->assertFalse($plugin2->is_empty($grade2));
+        $this->assertFalse($plugin3->is_empty($grade3));
+        $this->assertFalse($plugin4->is_empty($grade4));
+        $this->assertFalse($plugin5->is_empty($grade5));
+
+        // Check that there are also files generated.
+        $files = $DB->get_records('files', ['component' => 'assignfeedback_editpdf', 'filearea' => 'download']);
+        $this->assertCount(10, $files);
+
+        $deletedata = new assign_plugin_request_data($context, $assign1);
+        $deletedata->set_userids([$user1->id, $user3->id]);
+        $deletedata->populate_submissions_and_grades();
+        \assignfeedback_editpdf\privacy\provider::delete_feedback_for_grades($deletedata);
+
+        // Check that we now have no data for user 1.
+        $this->assertTrue($plugin1->is_empty($grade1));
+        // Check that user 2 data is still there.
+        $this->assertFalse($plugin2->is_empty($grade2));
+        // User 3 in assignment 1 should be gone.
+        $this->assertTrue($plugin3->is_empty($grade3));
+        // User 3 in assignment 2 should still be here.
+        $this->assertFalse($plugin4->is_empty($grade4));
+        // User 4 in assignment 2 should also still be here.
+        $this->assertFalse($plugin5->is_empty($grade5));
+
+        // Check the files as well.
+        $files = $DB->get_records('files', ['component' => 'assignfeedback_editpdf', 'filearea' => 'download']);
+        // We should now only have six records here.
+        $this->assertCount(6, $files);
+    }
 }
index f2f609f..839e957 100644 (file)
@@ -29,9 +29,7 @@ defined('MOODLE_INTERNAL') || die();
 require_once($CFG->dirroot . '/mod/assign/locallib.php');
 
 use \core_privacy\local\metadata\collection;
-use \core_privacy\local\metadata\provider as metadataprovider;
-use core_privacy\local\request\contextlist;
-use \mod_assign\privacy\assignfeedback_provider;
+use \core_privacy\local\request\contextlist;
 use \mod_assign\privacy\assign_plugin_request_data;
 use mod_assign\privacy\useridlist;
 
@@ -42,7 +40,10 @@ use mod_assign\privacy\useridlist;
  * @copyright  2018 Adrian Greeve <adrian@moodle.com>
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
-class provider implements metadataprovider, assignfeedback_provider {
+class provider implements
+        \core_privacy\local\metadata\provider,
+        \mod_assign\privacy\assignfeedback_provider,
+        \mod_assign\privacy\assignfeedback_user_provider {
 
     /**
      * Return meta data about this plugin.
@@ -75,6 +76,16 @@ class provider implements metadataprovider, assignfeedback_provider {
         // Not required.
     }
 
+    /**
+     * If you have tables that contain userids and you can generate entries in your tables without creating an
+     * entry in the assign_grades table then please fill in this method.
+     *
+     * @param  \core_privacy\local\request\userlist $userlist The userlist object
+     */
+    public static function get_userids_from_context(\core_privacy\local\request\userlist $userlist) {
+        // Not required.
+    }
+
     /**
      * Export all user data for this plugin.
      *
@@ -121,20 +132,40 @@ class provider implements metadataprovider, assignfeedback_provider {
      * @param  assign_plugin_request_data $requestdata Data useful for deleting user data.
      */
     public static function delete_feedback_for_grade(assign_plugin_request_data $requestdata) {
+        $requestdata->set_userids([$requestdata->get_user()->id]);
+        $requestdata->populate_submissions_and_grades();
+        self::delete_feedback_for_grades($requestdata);
+    }
+
+
+    /**
+     * Deletes all feedback for the grade ids / userids provided in a context.
+     * assign_plugin_request_data contains:
+     * - context
+     * - assign object
+     * - grade ids (pluginids)
+     * - user ids
+     * @param  assign_plugin_request_data $deletedata A class that contains the relevant information required for deletion.
+     */
+    public static function delete_feedback_for_grades(assign_plugin_request_data $deletedata) {
         global $DB;
 
-        $assign = $requestdata->get_assign();
+        if (empty($deletedata->get_gradeids())) {
+            return;
+        }
+
+        $assign = $deletedata->get_assign();
         $plugin = $assign->get_plugin_by_type('assignfeedback', 'file');
         $fileareas = $plugin->get_file_areas();
         $fs = get_file_storage();
+        list($sql, $params) = $DB->get_in_or_equal($deletedata->get_gradeids(), SQL_PARAMS_NAMED);
+        $params['assignment'] = $deletedata->get_assignid();
         foreach ($fileareas as $filearea => $notused) {
             // Delete feedback files.
-            $fs->delete_area_files($requestdata->get_context()->id, 'assignfeedback_file', $filearea,
-                    $requestdata->get_pluginobject()->id);
+            $fs->delete_area_files_select($deletedata->get_context()->id, 'assignfeedback_file', $filearea, $sql, $params);
         }
 
         // Delete table entries.
-        $DB->delete_records('assignfeedback_file', ['assignment' => $requestdata->get_assign()->get_instance()->id,
-                'grade' => $requestdata->get_pluginobject()->id]);
+        $DB->delete_records_select('assignfeedback_file', "assignment = :assignment AND grade $sql", $params);
     }
 }
index 5537755..38bd49b 100644 (file)
@@ -206,4 +206,63 @@ class assignfeedback_file_privacy_testcase extends \mod_assign\tests\mod_assign_
         // User 2's data should still be intact.
         $this->assertFalse($plugin2->is_empty($grade2));
     }
+
+    /**
+     * Test that a grade item is deleted for a user.
+     */
+    public function test_delete_feedback_for_grades() {
+        $this->resetAfterTest();
+        // Create course, assignment, submission, and then a feedback comment.
+        $course = $this->getDataGenerator()->create_course();
+        // Students.
+        $user1 = $this->getDataGenerator()->create_user();
+        $user2 = $this->getDataGenerator()->create_user();
+        $user3 = $this->getDataGenerator()->create_user();
+        $user4 = $this->getDataGenerator()->create_user();
+        // Teacher.
+        $user5 = $this->getDataGenerator()->create_user();
+        $this->getDataGenerator()->enrol_user($user1->id, $course->id, 'student');
+        $this->getDataGenerator()->enrol_user($user2->id, $course->id, 'student');
+        $this->getDataGenerator()->enrol_user($user3->id, $course->id, 'student');
+        $this->getDataGenerator()->enrol_user($user4->id, $course->id, 'student');
+        $this->getDataGenerator()->enrol_user($user5->id, $course->id, 'editingteacher');
+        $assign1 = $this->create_instance(['course' => $course]);
+        $assign2 = $this->create_instance(['course' => $course]);
+
+        $context = $assign1->get_context();
+
+        $feedbacktext = '<p>first comment for this test</p>';
+        list($plugin1, $grade1) = $this->create_feedback($assign1, $user1, $user5, 'Submission text', $feedbacktext);
+        $feedbacktext = '<p>Comment for second submission.</p>';
+        list($plugin2, $grade2) = $this->create_feedback($assign1, $user2, $user5, 'Submission text', $feedbacktext);
+        $feedbacktext = '<p>Comment for second submission.</p>';
+        list($plugin3, $grade3) = $this->create_feedback($assign1, $user3, $user5, 'Submission text', $feedbacktext);
+        $feedbacktext = '<p>Comment for second submission.</p>';
+        list($plugin4, $grade4) = $this->create_feedback($assign2, $user3, $user5, 'Submission text', $feedbacktext);
+        $feedbacktext = '<p>Comment for second submission.</p>';
+        list($plugin5, $grade5) = $this->create_feedback($assign2, $user4, $user5, 'Submission text', $feedbacktext);
+
+        // Check that we have data.
+        $this->assertFalse($plugin1->is_empty($grade1));
+        $this->assertFalse($plugin2->is_empty($grade2));
+        $this->assertFalse($plugin3->is_empty($grade3));
+        $this->assertFalse($plugin4->is_empty($grade4));
+        $this->assertFalse($plugin5->is_empty($grade5));
+
+        $deletedata = new assign_plugin_request_data($context, $assign1);
+        $deletedata->set_userids([$user1->id, $user3->id]);
+        $deletedata->populate_submissions_and_grades();
+        \assignfeedback_file\privacy\provider::delete_feedback_for_grades($deletedata);
+
+        // Check that we now have no data.
+        $this->assertTrue($plugin1->is_empty($grade1));
+        // User 2's data should still be intact.
+        $this->assertFalse($plugin2->is_empty($grade2));
+        // User 3's data in assignment 1 should be gone.
+        $this->assertTrue($plugin3->is_empty($grade3));
+        // User 3's data in assignment 2 should still be intact.
+        $this->assertFalse($plugin4->is_empty($grade4));
+        // User 4's data in assignment 2 should still be intact.
+        $this->assertFalse($plugin5->is_empty($grade5));
+    }
 }
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 6f95fd2..8a7336c 100644 (file)
@@ -41,7 +41,9 @@ use \mod_assign\privacy\assign_plugin_request_data;
  * @copyright  2018 Adrian Greeve <adrian@moodle.com>
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
-class provider implements metadataprovider, \mod_assign\privacy\assignsubmission_provider {
+class provider implements metadataprovider,
+        \mod_assign\privacy\assignsubmission_provider,
+        \mod_assign\privacy\assignsubmission_user_provider {
 
     /**
      * Return meta data about this plugin.
@@ -89,6 +91,21 @@ class provider implements metadataprovider, \mod_assign\privacy\assignsubmission
         $useridlist->add_from_sql($sql, $params);
     }
 
+    /**
+     * If you have tables that contain userids and you can generate entries in your tables without creating an
+     * entry in the assign_submission table then please fill in this method.
+     *
+     * @param  \core_privacy\local\request\userlist $userlist The userlist object
+     */
+    public static function get_userids_from_context(\core_privacy\local\request\userlist $userlist) {
+        $context = $userlist->get_context();
+        if ($context->contextlevel != CONTEXT_MODULE) {
+            return;
+        }
+        comments_provider::get_users_in_context_from_sql($userlist, 'c', 'assignsubmission_comments', 'submission_comments',
+                $context->id);
+    }
+
     /**
      * Export all user data for this plugin.
      *
@@ -128,4 +145,20 @@ class provider implements metadataprovider, \mod_assign\privacy\assignsubmission
             [$exportdata->get_context()->id]);
         comments_provider::delete_comments_for_user($contextlist, 'assignsubmission_comments', 'submission_comments');
     }
+
+    /**
+     * Deletes all submissions for the submission ids / userids provided in a context.
+     * assign_plugin_request_data contains:
+     * - context
+     * - assign object
+     * - submission ids (pluginids)
+     * - user ids
+     * @param  assign_plugin_request_data $deletedata A class that contains the relevant information required for deletion.
+     */
+    public static function delete_submissions(assign_plugin_request_data $deletedata) {
+        $userlist = new \core_privacy\local\request\approved_userlist($deletedata->get_context(), 'assignsubmission_comments',
+                $deletedata->get_userids());
+        comments_provider::delete_comments_for_users($userlist, 'assignsubmission_comments', 'submission_comments');
+    }
+
 }
index e5917e6..c02e795 100644 (file)
@@ -134,6 +134,51 @@ class assignsubmission_comments_privacy_testcase extends \mod_assign\tests\mod_a
         $this->assertEquals($user1->id, $useridlist->get_userids()[0]->id);
     }
 
+    /**
+     * Test returning users related to a given context.
+     */
+    public function test_get_userids_from_context() {
+        // Get a bunch of users making comments.
+        // Some in one context some in another.
+        $this->resetAfterTest();
+        $course = $this->getDataGenerator()->create_course();
+        // Only in first context.
+        $user1 = $this->getDataGenerator()->create_user();
+        $user2 = $this->getDataGenerator()->create_user();
+        // First and second context.
+        $user3 = $this->getDataGenerator()->create_user();
+        // Second context only.
+        $user4 = $this->getDataGenerator()->create_user();
+        $this->getDataGenerator()->enrol_user($user1->id, $course->id, 'student');
+        $assign1 = $this->create_instance(['course' => $course]);
+        $assign2 = $this->create_instance(['course' => $course]);
+
+        $assigncontext1 = $assign1->get_context();
+        $assigncontext2 = $assign2->get_context();
+
+        $user1comment = 'Comment from user 1';
+        list($plugin, $submission, $comment) = $this->create_comment_submission($assign1, $user1, $user1comment);
+        $user2comment = 'From user 2';
+        $this->setUser($user2);
+        $comment->add($user2comment);
+        $user3comment = 'User 3 comment';
+        $this->setUser($user3);
+        $comment->add($user3comment);
+        $user4comment = 'Comment from user 4';
+        list($plugin, $submission, $comment) = $this->create_comment_submission($assign2, $user4, $user4comment);
+        $user3secondcomment = 'Comment on user 4 post.';
+        $this->setUser($user3);
+        $comment->add($user3comment);
+
+        $userlist = new \core_privacy\local\request\userlist($assigncontext1, 'assignsubmission_comments');
+        \assignsubmission_comments\privacy\provider::get_userids_from_context($userlist);
+        $userids = $userlist->get_userids();
+        $this->assertCount(3, $userids);
+        // Two is the key for user 3.
+        $this->assertEquals(2, array_search($user3->id, $userids));
+        $this->assertFalse(array_search($user4->id, $userids));
+    }
+
     /**
      * Test that comments are exported for a user.
      */
@@ -259,4 +304,55 @@ class assignsubmission_comments_privacy_testcase extends \mod_assign\tests\mod_a
             $this->assertNotEquals($user1->id, $result->userid);
         }
     }
+
+    /**
+     * Test deletion of all submissions for a context works.
+     */
+    public function test_delete_submissions() {
+        global $DB;
+        // Get a bunch of users making comments.
+        // Some in one context some in another.
+        $this->resetAfterTest();
+        $course = $this->getDataGenerator()->create_course();
+        // Only in first context.
+        $user1 = $this->getDataGenerator()->create_user();
+        $user2 = $this->getDataGenerator()->create_user();
+        // First and second context.
+        $user3 = $this->getDataGenerator()->create_user();
+        // Second context only.
+        $user4 = $this->getDataGenerator()->create_user();
+        $this->getDataGenerator()->enrol_user($user1->id, $course->id, 'student');
+        $assign1 = $this->create_instance(['course' => $course]);
+        $assign2 = $this->create_instance(['course' => $course]);
+
+        $assigncontext1 = $assign1->get_context();
+        $assigncontext2 = $assign2->get_context();
+
+        $user1comment = 'Comment from user 1';
+        list($plugin, $submission, $comment) = $this->create_comment_submission($assign1, $user1, $user1comment);
+        $user2comment = 'From user 2';
+        $this->setUser($user2);
+        $comment->add($user2comment);
+        $user3comment = 'User 3 comment';
+        $this->setUser($user3);
+        $comment->add($user3comment);
+        $user4comment = 'Comment from user 4';
+        list($plugin, $submission, $comment) = $this->create_comment_submission($assign2, $user4, $user4comment);
+        $user3secondcomment = 'Comment on user 4 post.';
+        $this->setUser($user3);
+        $comment->add($user3comment);
+
+        // There should be three entries. One for the first three users.
+        $results = $DB->get_records('comments', ['contextid' => $assigncontext1->id]);
+        $this->assertCount(3, $results);
+
+        $deletedata = new \mod_assign\privacy\assign_plugin_request_data($assigncontext1, $assign1);
+        $deletedata->set_userids([$user1->id, $user3->id]);
+        \assignsubmission_comments\privacy\provider::delete_submissions($deletedata);
+
+        // We should be left with just a comment from user 2.
+        $results = $DB->get_records('comments', ['contextid' => $assigncontext1->id]);
+        $this->assertCount(1, $results);
+        $this->assertEquals($user2comment, current($results)->content);
+    }
 }
index a10e67a..f02f6dc 100644 (file)
@@ -29,7 +29,6 @@ defined('MOODLE_INTERNAL') || die();
 require_once($CFG->dirroot . '/mod/assign/locallib.php');
 
 use \core_privacy\local\metadata\collection;
-use \core_privacy\local\metadata\provider as metadataprovider;
 use \core_privacy\local\request\writer;
 use \core_privacy\local\request\contextlist;
 use \mod_assign\privacy\assign_plugin_request_data;
@@ -41,7 +40,10 @@ use \mod_assign\privacy\assign_plugin_request_data;
  * @copyright  2018 Adrian Greeve <adrian@moodle.com>
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
-class provider implements metadataprovider, \mod_assign\privacy\assignsubmission_provider {
+class provider implements
+        \core_privacy\local\metadata\provider,
+        \mod_assign\privacy\assignsubmission_provider,
+        \mod_assign\privacy\assignsubmission_user_provider {
 
     /**
      * Return meta data about this plugin.
@@ -73,6 +75,16 @@ class provider implements metadataprovider, \mod_assign\privacy\assignsubmission
         // No need.
     }
 
+    /**
+     * If you have tables that contain userids and you can generate entries in your tables without creating an
+     * entry in the assign_submission table then please fill in this method.
+     *
+     * @param  userlist $userlist The userlist object
+     */
+    public static function get_userids_from_context(\core_privacy\local\request\userlist $userlist) {
+        // Not required.
+    }
+
     /**
      * Export all user data for this plugin.
      *
@@ -122,7 +134,7 @@ class provider implements metadataprovider, \mod_assign\privacy\assignsubmission
     }
 
     /**
-     * A call to this method should delete user data (where practicle) using the userid and submission.
+     * A call to this method should delete user data (where practical) using the userid and submission.
      *
      * @param  assign_plugin_request_data $deletedata Details about the user and context to focus the deletion.
      */
@@ -137,7 +149,32 @@ class provider implements metadataprovider, \mod_assign\privacy\assignsubmission
         $fs->delete_area_files($deletedata->get_context()->id, 'assignsubmission_file', ASSIGNSUBMISSION_FILE_FILEAREA,
                 $submissionid);
 
-        $DB->delete_records('assignsubmission_file', ['assignment' => $deletedata->get_assign()->get_instance()->id,
-                'submission' => $submissionid]);
+        $DB->delete_records('assignsubmission_file', ['assignment' => $deletedata->get_assignid(), 'submission' => $submissionid]);
+    }
+
+    /**
+     * Deletes all submissions for the submission ids / userids provided in a context.
+     * assign_plugin_request_data contains:
+     * - context
+     * - assign object
+     * - submission ids (pluginids)
+     * - user ids
+     * @param  assign_plugin_request_data $deletedata A class that contains the relevant information required for deletion.
+     */
+    public static function delete_submissions(assign_plugin_request_data $deletedata) {
+        global $DB;
+
+        \core_plagiarism\privacy\provider::delete_plagiarism_for_users($deletedata->get_userids(), $deletedata->get_context());
+
+        if (empty($deletedata->get_submissionids())) {
+            return;
+        }
+        $fs = get_file_storage();
+        list($sql, $params) = $DB->get_in_or_equal($deletedata->get_submissionids(), SQL_PARAMS_NAMED);
+        $fs->delete_area_files_select($deletedata->get_context()->id, 'assignsubmission_file', ASSIGNSUBMISSION_FILE_FILEAREA,
+                $sql, $params);
+
+        $params['assignid'] = $deletedata->get_assignid();
+        $DB->delete_records_select('assignsubmission_file', "assignment = :assignid AND submission $sql", $params);
     }
 }
index 61f6841..99dda26 100644 (file)
@@ -172,4 +172,77 @@ class assignsubmission_file_privacy_testcase extends \mod_assign\tests\mod_assig
         // There should be files here.
         $this->assertFalse($plugin2->is_empty($submission2));
     }
+
+    /**
+     * Test deletion of bulk submissions for a context.
+     */
+    public function test_delete_submissions() {
+        global $DB;
+
+        $this->resetAfterTest();
+        // Create course, assignment, submission, and then a feedback comment.
+        $course = $this->getDataGenerator()->create_course();
+        // Student.
+        $user1 = $this->getDataGenerator()->create_user();
+        $user2 = $this->getDataGenerator()->create_user();
+        $user3 = $this->getDataGenerator()->create_user();
+        $user4 = $this->getDataGenerator()->create_user();
+
+        $this->getDataGenerator()->enrol_user($user1->id, $course->id, 'student');
+        $this->getDataGenerator()->enrol_user($user2->id, $course->id, 'student');
+        $this->getDataGenerator()->enrol_user($user3->id, $course->id, 'student');
+        $this->getDataGenerator()->enrol_user($user4->id, $course->id, 'student');
+
+        $assign1 = $this->create_instance(['course' => $course]);
+        $assign2 = $this->create_instance(['course' => $course]);
+
+        $context1 = $assign1->get_context();
+        $context2 = $assign2->get_context();
+
+        $student1filename = 'user1file.pdf';
+        list($plugin1, $submission1) = $this->create_file_submission($assign1, $user1, $student1filename);
+        $student2filename = 'user2file.pdf';
+        list($plugin2, $submission2) = $this->create_file_submission($assign1, $user2, $student2filename);
+        $student3filename = 'user3file.pdf';
+        list($plugin3, $submission3) = $this->create_file_submission($assign1, $user3, $student3filename);
+        $student4filename = 'user4file.pdf';
+        list($plugin4, $submission4) = $this->create_file_submission($assign2, $user4, $student4filename);
+        $student5filename = 'user5file.pdf';
+        list($plugin5, $submission5) = $this->create_file_submission($assign2, $user3, $student5filename);
+
+        $submissionids = [
+            $submission1->id,
+            $submission3->id
+        ];
+
+        $userids = [
+            $user1->id,
+            $user3->id
+        ];
+
+        $data = $DB->get_records('files', ['contextid' => $context1->id, 'component' => 'assignsubmission_file']);
+        $this->assertCount(6, $data);
+
+        $data = $DB->get_records('assignsubmission_file', ['assignment' => $assign1->get_instance()->id]);
+        $this->assertCount(3, $data);
+
+        // Records in the second assignment (not being touched).
+        $data = $DB->get_records('assignsubmission_file', ['assignment' => $assign2->get_instance()->id]);
+        $this->assertCount(2, $data);
+
+        $deletedata = new \mod_assign\privacy\assign_plugin_request_data($context1, $assign1);
+        $deletedata->set_userids($userids);
+        $deletedata->populate_submissions_and_grades();
+        \assignsubmission_file\privacy\provider::delete_submissions($deletedata);
+        $data = $DB->get_records('files', ['contextid' => $context1->id, 'component' => 'assignsubmission_file']);
+        $this->assertCount(2, $data);
+
+        // Submission 1 and 3 have been removed. We should be left with submission2.
+        $data = $DB->get_records('assignsubmission_file', ['assignment' => $assign1->get_instance()->id]);
+        $this->assertCount(1, $data);
+
+        // This should be untouched.
+        $data = $DB->get_records('assignsubmission_file', ['assignment' => $assign2->get_instance()->id]);
+        $this->assertCount(2, $data);
+    }
 }
index fc5e274..fa660b9 100644 (file)
@@ -29,7 +29,6 @@ defined('MOODLE_INTERNAL') || die();
 require_once($CFG->dirroot . '/mod/assign/locallib.php');
 
 use \core_privacy\local\metadata\collection;
-use \core_privacy\local\metadata\provider as metadataprovider;
 use \core_privacy\local\request\writer;
 use \core_privacy\local\request\contextlist;
 use \mod_assign\privacy\assign_plugin_request_data;
@@ -41,7 +40,10 @@ use \mod_assign\privacy\assign_plugin_request_data;
  * @copyright  2018 Adrian Greeve <adrian@moodle.com>
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
-class provider implements metadataprovider, \mod_assign\privacy\assignsubmission_provider {
+class provider implements
+        \core_privacy\local\metadata\provider,
+        \mod_assign\privacy\assignsubmission_provider,
+        \mod_assign\privacy\assignsubmission_user_provider {
 
     /**
      * Return meta data about this plugin.
@@ -79,6 +81,16 @@ class provider implements metadataprovider, \mod_assign\privacy\assignsubmission
         // No need.
     }
 
+    /**
+     * If you have tables that contain userids and you can generate entries in your tables without creating an
+     * entry in the assign_submission table then please fill in this method.
+     *
+     * @param  \core_privacy\local\request\userlist $userlist The userlist object
+     */
+    public static function get_userids_from_context(\core_privacy\local\request\userlist $userlist) {
+        // Not required.
+    }
+
     /**
      * Export all user data for this plugin.
      *
@@ -136,7 +148,7 @@ class provider implements metadataprovider, \mod_assign\privacy\assignsubmission
                 ASSIGNSUBMISSION_ONLINETEXT_FILEAREA);
 
         // Delete the records in the table.
-        $DB->delete_records('assignsubmission_onlinetext', ['assignment' => $requestdata->get_assign()->get_instance()->id]);
+        $DB->delete_records('assignsubmission_onlinetext', ['assignment' => $requestdata->get_assignid()]);
     }
 
     /**
@@ -157,7 +169,33 @@ class provider implements metadataprovider, \mod_assign\privacy\assignsubmission
                 $submissionid);
 
         // Delete the records in the table.
-        $DB->delete_records('assignsubmission_onlinetext', ['assignment' => $deletedata->get_assign()->get_instance()->id,
+        $DB->delete_records('assignsubmission_onlinetext', ['assignment' => $deletedata->get_assignid(),
                 'submission' => $submissionid]);
     }
+
+    /**
+     * Deletes all submissions for the submission ids / userids provided in a context.
+     * assign_plugin_request_data contains:
+     * - context
+     * - assign object
+     * - submission ids (pluginids)
+     * - user ids
+     * @param  assign_plugin_request_data $deletedata A class that contains the relevant information required for deletion.
+     */
+    public static function delete_submissions(assign_plugin_request_data $deletedata) {
+        global $DB;
+
+        \core_plagiarism\privacy\provider::delete_plagiarism_for_users($deletedata->get_userids(), $deletedata->get_context());
+        if (empty($deletedata->get_submissionids())) {
+            return;
+        }
+
+        $fs = get_file_storage();
+        list($sql, $params) = $DB->get_in_or_equal($deletedata->get_submissionids(), SQL_PARAMS_NAMED);
+        $fs->delete_area_files_select($deletedata->get_context()->id,
+                'assignsubmission_onlinetext', ASSIGNSUBMISSION_ONLINETEXT_FILEAREA, $sql, $params);
+
+        $params['assignid'] = $deletedata->get_assignid();
+        $DB->delete_records_select('assignsubmission_onlinetext', "assignment = :assignid AND submission $sql", $params);
+    }
 }
index ce1dd50..ed7342c 100644 (file)
@@ -67,7 +67,7 @@ class assignsubmission_online_privacy_testcase extends \mod_assign\tests\mod_ass
      * Quick test to make sure that get_metadata returns something.
      */
     public function test_get_metadata() {
-        $collection = new \core_privacy\local\metadata\collection('assignsubmission_file');
+        $collection = new \core_privacy\local\metadata\collection('assignsubmission_onlinetext');
         $collection = \assignsubmission_onlinetext\privacy\provider::get_metadata($collection);
         $this->assertNotEmpty($collection);
     }
@@ -161,4 +161,56 @@ class assignsubmission_online_privacy_testcase extends \mod_assign\tests\mod_ass
         // But there is for the second submission.
         $this->assertFalse($plugin2->is_empty($submission2));
     }
+
+    public function test_delete_submissions() {
+        global $DB;
+
+        $this->resetAfterTest();
+
+        $course = $this->getDataGenerator()->create_course();
+        $user1 = $this->getDataGenerator()->create_user();
+        $user2 = $this->getDataGenerator()->create_user();
+        $user3 = $this->getDataGenerator()->create_user();
+        // Only makes submissions in the second assignment.
+        $user4 = $this->getDataGenerator()->create_user();
+
+        $this->getDataGenerator()->enrol_user($user1->id, $course->id, 'student');
+        $this->getDataGenerator()->enrol_user($user2->id, $course->id, 'student');
+        $this->getDataGenerator()->enrol_user($user3->id, $course->id, 'student');
+        $this->getDataGenerator()->enrol_user($user4->id, $course->id, 'student');
+
+        $assign1 = $this->create_instance(['course' => $course]);
+        $assign2 = $this->create_instance(['course' => $course]);
+
+        $context1 = $assign1->get_context();
+        $context2 = $assign2->get_context();
+
+        $student1text = 'Student one\'s text.';
+        list($plugin1, $submission1) = $this->create_online_submission($assign1, $user1, $student1text);
+        $student2text = 'Student two\'s text.';
+        list($plugin2, $submission2) = $this->create_online_submission($assign1, $user2, $student2text);
+        $student3text = 'Student two\'s text.';
+        list($plugin3, $submission3) = $this->create_online_submission($assign1, $user3, $student3text);
+        // Now for submissions in assignment two.
+        $student3text2 = 'Student two\'s text for the second assignment.';
+        list($plugin4, $submission4) = $this->create_online_submission($assign2, $user3, $student3text2);
+        $student4text = 'Student four\'s text.';
+        list($plugin5, $submission5) = $this->create_online_submission($assign2, $user4, $student4text);
+
+        $data = $DB->get_records('assignsubmission_onlinetext', ['assignment' => $assign1->get_instance()->id]);
+        $this->assertCount(3, $data);
+        // Delete the submissions for user 1 and 3.
+        $requestdata = new \mod_assign\privacy\assign_plugin_request_data($context1, $assign1);
+        $requestdata->set_userids([$user1->id, $user2->id]);
+        $requestdata->populate_submissions_and_grades();
+        \assignsubmission_onlinetext\privacy\provider::delete_submissions($requestdata);
+
+        // There should only be one record left for assignment one.
+        $data = $DB->get_records('assignsubmission_onlinetext', ['assignment' => $assign1->get_instance()->id]);
+        $this->assertCount(1, $data);
+
+        // Check that the second assignment has not been touched.
+        $data = $DB->get_records('assignsubmission_onlinetext', ['assignment' => $assign2->get_instance()->id]);
+        $this->assertCount(2, $data);
+    }
 }
index bcae99d..185d5aa 100644 (file)
@@ -148,6 +148,95 @@ class mod_assign_privacy_testcase extends provider_testcase {
         $this->assertEmpty(array_diff($usercontextids, $contextlist->get_contextids()));
     }
 
+    /**
+     * Test returning a list of user IDs related to a context (assign).
+     */
+    public function test_get_users_in_context() {
+        global $DB;
+
+        $this->resetAfterTest();
+
+        $course = $this->getDataGenerator()->create_course();
+
+        // Only made a comment on a submission.
+        $user1 = $this->getDataGenerator()->create_user();
+        // User 2 only has information about an activity override.
+        $user2 = $this->getDataGenerator()->create_user();
+        // User 3 made a submission.
+        $user3 = $this->getDataGenerator()->create_user();
+        // User 4 makes a submission and it is marked by the teacher.
+        $user4 = $this->getDataGenerator()->create_user();
+        // Grading and providing feedback as a teacher.
+        $user5 = $this->getDataGenerator()->create_user();
+        // This user has no entries and should not show up.
+        $user6 = $this->getDataGenerator()->create_user();
+
+        $this->getDataGenerator()->enrol_user($user1->id, $course->id, 'student');
+        $this->getDataGenerator()->enrol_user($user2->id, $course->id, 'student');
+        $this->getDataGenerator()->enrol_user($user3->id, $course->id, 'student');
+        $this->getDataGenerator()->enrol_user($user4->id, $course->id, 'student');
+        $this->getDataGenerator()->enrol_user($user5->id, $course->id, 'editingteacher');
+        $this->getDataGenerator()->enrol_user($user6->id, $course->id, 'student');
+
+        $assign1 = $this->create_instance(['course' => $course,
+                'assignsubmission_onlinetext_enabled' => true,
+                'assignfeedback_comments_enabled' => true]);
+        $assign2 = $this->create_instance(['course' => $course]);
+
+        $context = $assign1->get_context();
+
+        // Jam an entry in the comments table for user 1.
+        $comment = (object) [
+            'contextid' => $context->id,
+            'component' => 'assignsubmission_comments',
+            'commentarea' => 'submission_comments',
+            'itemid' => 5,
+            'content' => 'A comment by user 1',
+            'format' => 0,
+            'userid' => $user1->id,
+            'timecreated' => time()
+        ];
+        $DB->insert_record('comments', $comment);
+
+        $this->setUser($user5); // Set the user to the teacher.
+
+        $overridedata = new \stdClass();
+        $overridedata->assignid = $assign1->get_instance()->id;
+        $overridedata->userid = $user2->id;
+        $overridedata->duedate = time();
+        $overridedata->allowsubmissionsfromdate = time();
+        $overridedata->cutoffdate = time();
+        $DB->insert_record('assign_overrides', $overridedata);
+
+        $submissiontext = 'My first submission';
+        $submission = $this->create_submission($assign1, $user3, $submissiontext);
+
+        $submissiontext = 'My first submission';
+        $submission = $this->create_submission($assign1, $user4, $submissiontext);
+
+        $this->setUser($user5);
+
+        $grade = '72.00';
+        $teachercommenttext = 'This is better. Thanks.';
+        $data = new \stdClass();
+        $data->attemptnumber = 1;
+        $data->grade = $grade;
+        $data->assignfeedbackcomments_editor = ['text' => $teachercommenttext, 'format' => FORMAT_MOODLE];
+
+        // Give the submission a grade.
+        $assign1->save_grade($user4->id, $data);
+
+        $userlist = new \core_privacy\local\request\userlist($context, 'assign');
+        provider::get_users_in_context($userlist);
+        $userids = $userlist->get_userids();
+        $this->assertTrue(in_array($user1->id, $userids));
+        $this->assertTrue(in_array($user2->id, $userids));
+        $this->assertTrue(in_array($user3->id, $userids));
+        $this->assertTrue(in_array($user4->id, $userids));
+        $this->assertTrue(in_array($user5->id, $userids));
+        $this->assertFalse(in_array($user6->id, $userids));
+    }
+
     /**
      * Test that a student with multiple submissions and grades is returned with the correct data.
      */
@@ -577,4 +666,150 @@ class mod_assign_privacy_testcase extends provider_testcase {
         // The remaining event should be for user 1.
         $this->assertEquals($user1->id, $record->userid);
     }
+
+    /**
+     * A test for deleting all user data for a bunch of users.
+     */
+    public function test_delete_data_for_users() {
+        global $DB;
+
+        $this->resetAfterTest();
+
+        $course = $this->getDataGenerator()->create_course();
+
+        // Only made a comment on a submission.
+        $user1 = $this->getDataGenerator()->create_user();
+        // User 2 only has information about an activity override.
+        $user2 = $this->getDataGenerator()->create_user();
+        // User 3 made a submission.
+        $user3 = $this->getDataGenerator()->create_user();
+        // User 4 makes a submission and it is marked by the teacher.
+        $user4 = $this->getDataGenerator()->create_user();
+        // Grading and providing feedback as a teacher.
+        $user5 = $this->getDataGenerator()->create_user();
+        // This user has entries in assignment 2 and should not have their data deleted.
+        $user6 = $this->getDataGenerator()->create_user();
+
+        $this->getDataGenerator()->enrol_user($user1->id, $course->id, 'student');
+        $this->getDataGenerator()->enrol_user($user2->id, $course->id, 'student');
+        $this->getDataGenerator()->enrol_user($user3->id, $course->id, 'student');
+        $this->getDataGenerator()->enrol_user($user4->id, $course->id, 'student');
+        $this->getDataGenerator()->enrol_user($user5->id, $course->id, 'editingteacher');
+        $this->getDataGenerator()->enrol_user($user6->id, $course->id, 'student');
+
+        $assign1 = $this->create_instance(['course' => $course,
+                'assignsubmission_onlinetext_enabled' => true,
+                'assignfeedback_comments_enabled' => true]);
+        $assign2 = $this->create_instance(['course' => $course,
+                'assignsubmission_onlinetext_enabled' => true,
+                'assignfeedback_comments_enabled' => true]);
+
+        $context = $assign1->get_context();
+
+        // Jam an entry in the comments table for user 1.
+        $comment = (object) [
+            'contextid' => $context->id,
+            'component' => 'assignsubmission_comments',
+            'commentarea' => 'submission_comments',
+            'itemid' => 5,
+            'content' => 'A comment by user 1',
+            'format' => 0,
+            'userid' => $user1->id,
+            'timecreated' => time()
+        ];
+        $DB->insert_record('comments', $comment);
+
+        $this->setUser($user5); // Set the user to the teacher.
+
+        $overridedata = new \stdClass();
+        $overridedata->assignid = $assign1->get_instance()->id;
+        $overridedata->userid = $user2->id;
+        $overridedata->duedate = time();
+        $overridedata->allowsubmissionsfromdate = time();
+        $overridedata->cutoffdate = time();
+        $DB->insert_record('assign_overrides', $overridedata);
+
+        $submissiontext = 'My first submission';
+        $submission = $this->create_submission($assign1, $user3, $submissiontext);
+
+        $submissiontext = 'My first submission';
+        $submission = $this->create_submission($assign1, $user4, $submissiontext);
+
+        $submissiontext = 'My first submission';
+        $submission = $this->create_submission($assign2, $user6, $submissiontext);
+
+        $this->setUser($user5);
+
+        $grade = '72.00';
+        $teachercommenttext = 'This is better. Thanks.';
+        $data = new \stdClass();
+        $data->attemptnumber = 1;
+        $data->grade = $grade;
+        $data->assignfeedbackcomments_editor = ['text' => $teachercommenttext, 'format' => FORMAT_MOODLE];
+
+        // Give the submission a grade.
+        $assign1->save_grade($user4->id, $data);
+
+        $this->setUser($user5);
+
+        $grade = '81.00';
+        $teachercommenttext = 'This is nice.';
+        $data = new \stdClass();
+        $data->attemptnumber = 1;
+        $data->grade = $grade;
+        $data->assignfeedbackcomments_editor = ['text' => $teachercommenttext, 'format' => FORMAT_MOODLE];
+
+        // Give the submission a grade.
+        $assign2->save_grade($user6->id, $data);
+
+        // Check data is in place.
+        $data = $DB->get_records('assign_submission');
+        // We should have one entry for user 3 and two entries each for user 4 and 6.
+        $this->assertCount(5, $data);
+        $usercounts = [
+            $user3->id => 0,
+            $user4->id => 0,
+            $user6->id => 0
+        ];
+        foreach ($data as $datum) {
+            $usercounts[$datum->userid]++;
+        }
+        $this->assertEquals(1, $usercounts[$user3->id]);
+        $this->assertEquals(2, $usercounts[$user4->id]);
+        $this->assertEquals(2, $usercounts[$user6->id]);
+
+        $data = $DB->get_records('assign_grades');
+        // Two entries in assign_grades, one for each grade given.
+        $this->assertCount(2, $data);
+
+        $data = $DB->get_records('assign_overrides');
+        $this->assertCount(1, $data);
+
+        $data = $DB->get_records('comments');
+        $this->assertCount(1, $data);
+
+        $userlist = new \core_privacy\local\request\approved_userlist($context, 'assign', [$user1->id, $user2->id]);
+        provider::delete_data_for_users($userlist);
+
+        $data = $DB->get_records('assign_overrides');
+        $this->assertEmpty($data);
+
+        $data = $DB->get_records('comments');
+        $this->assertEmpty($data);
+
+        $data = $DB->get_records('assign_submission');
+        // No change here.
+        $this->assertCount(5, $data);
+
+        $userlist = new \core_privacy\local\request\approved_userlist($context, 'assign', [$user3->id, $user5->id]);
+        provider::delete_data_for_users($userlist);
+
+        $data = $DB->get_records('assign_submission');
+        // Only the record for user 3 has been deleted.
+        $this->assertCount(4, $data);
+
+        $data = $DB->get_records('assign_grades');
+        // Grades should be unchanged.
+        $this->assertCount(2, $data);
+    }
 }
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 10b6d7b..b80cfff 100644 (file)
@@ -162,22 +162,7 @@ class provider implements
         $userlist->add_from_sql('userid', $sql, $params);
 
         // Find users with comments.
-        $sql = "SELECT dr.id
-                  FROM {context} c
-                  JOIN {course_modules} cm ON cm.id = c.instanceid AND c.contextlevel = :contextlevel
-                  JOIN {modules} m ON m.id = cm.module AND m.name = :modname
-                  JOIN {data} d ON d.id = cm.instance
-                  JOIN {data_records} dr ON dr.dataid = d.id
-                 WHERE c.id = :contextid";
-
-        $params = [
-            'modname'       => 'data',
-            'contextid'     => $context->id,
-            'contextlevel'  => CONTEXT_MODULE,
-        ];
-
-        \core_comment\privacy\provider::get_users_in_context_from_sql(
-            $userlist, 'com', 'mod_data', 'database_entry', $sql, $params);
+        \core_comment\privacy\provider::get_users_in_context_from_sql($userlist, 'com', 'mod_data', 'database_entry', $context->id);
 
         // Find users with ratings.
         $sql = "SELECT dr.id
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 4c630f6..66cf696 100644 (file)
@@ -136,22 +136,8 @@ class provider implements
         $userlist->add_from_sql('userid', $sql, $params);
 
         // Find users with glossary comments.
-        $sql = "SELECT ge.id
-                  FROM {context} c
-                  JOIN {course_modules} cm ON cm.id = c.instanceid AND c.contextlevel = :contextlevel
-                  JOIN {modules} m ON m.id = cm.module AND m.name = :modname
-                  JOIN {glossary} g ON g.id = cm.instance
-                  JOIN {glossary_entries} ge ON ge.glossaryid = g.id
-                 WHERE c.id = :contextid";
-
-        $params = [
-            'contextid' => $context->id,
-            'contextlevel' => CONTEXT_MODULE,
-            'modname' => 'glossary',
-        ];
-
-        \core_comment\privacy\provider::get_users_in_context_from_sql(
-            $userlist, 'com', 'mod_glossary', 'glossary_entry', $sql, $params);
+        \core_comment\privacy\provider::get_users_in_context_from_sql($userlist, 'com', 'mod_glossary', 'glossary_entry',
+                $context->id);
 
         // Find users with glossary ratings.
         $sql = "SELECT ge.id
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 2e577d6..9929590 100644 (file)
@@ -133,6 +133,30 @@ class provider implements
      * @return  contextlist     $contextlist The contextlist containing the list of contexts used in this plugin.
      */
     public static function get_contexts_for_userid(int $userid) : contextlist {
+        $resultset = new contextlist();
+
+        // Users who attempted the quiz.
+        $sql = "SELECT c.id
+                  FROM {context} c
+                  JOIN {course_modules} cm ON cm.id = c.instanceid AND c.contextlevel = :contextlevel
+                  JOIN {modules} m ON m.id = cm.module AND m.name = :modname
+                  JOIN {quiz} q ON q.id = cm.instance
+                  JOIN {quiz_attempts} qa ON qa.quiz = q.id
+                 WHERE qa.userid = :userid AND qa.preview = 0";
+        $params = ['contextlevel' => CONTEXT_MODULE, 'modname' => 'quiz', 'userid' => $userid];
+        $resultset->add_from_sql($sql, $params);
+
+        // Users with quiz overrides.
+        $sql = "SELECT c.id
+                  FROM {context} c
+                  JOIN {course_modules} cm ON cm.id = c.instanceid AND c.contextlevel = :contextlevel
+                  JOIN {modules} m ON m.id = cm.module AND m.name = :modname
+                  JOIN {quiz} q ON q.id = cm.instance
+                  JOIN {quiz_overrides} qo ON qo.quiz = q.id
+                 WHERE qo.userid = :userid";
+        $params = ['contextlevel' => CONTEXT_MODULE, 'modname' => 'quiz', 'userid' => $userid];
+        $resultset->add_from_sql($sql, $params);
+
         // Get the SQL used to link indirect question usages for the user.
         // This includes where a user is the manual marker on a question attempt.
         $qubaid = \core_question\privacy\provider::get_related_question_usages_for_user('rel', 'mod_quiz', 'qa.uniqueid', $userid);
@@ -144,33 +168,16 @@ class provider implements
                   JOIN {modules} m ON m.id = cm.module AND m.name = :modname
                   JOIN {quiz} q ON q.id = cm.instance
                   JOIN {quiz_attempts} qa ON qa.quiz = q.id
-             LEFT JOIN {quiz_overrides} qo ON qo.quiz = q.id AND qo.userid = :qouserid
             " . $qubaid->from . "
-            WHERE (
-                qa.userid = :qauserid OR
-                " . $qubaid->where() . " OR
-                qo.id IS NOT NULL
-            ) AND qa.preview = 0
-        ";
-
-        $params = array_merge(
-                [
-                    'contextlevel'      => CONTEXT_MODULE,
-                    'modname'           => 'quiz',
-                    'qauserid'          => $userid,
-                    'qouserid'          => $userid,
-                ],
-                $qubaid->from_where_params()
-            );
-
-        $resultset = new contextlist();
+            WHERE " . $qubaid->where() . " AND qa.preview = 0";
+        $params = ['contextlevel' => CONTEXT_MODULE, 'modname' => 'quiz'] + $qubaid->from_where_params();
         $resultset->add_from_sql($sql, $params);
 
         return $resultset;
     }
 
     /**
-     * Delete all data for all users in the specified context.
+     * Export all user data for the specified user, in the specified contexts.
      *
      * @param   approved_contextlist    $contextlist    The approved contexts to export information for.
      */
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 100644 (file)
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 9e5ca4b..9078dd3 100644 (file)
@@ -56,6 +56,36 @@ class mod_quiz_privacy_provider_testcase extends \core_privacy\tests\provider_te
         $this->assertEmpty($contextlist);
     }
 
+    /**
+     * Test for provider::get_contexts_for_userid() when there is no quiz attempt at all.
+     */
+    public function test_get_contexts_for_userid_no_attempt_with_override() {
+        global $DB;
+        $this->resetAfterTest(true);
+
+        $course = $this->getDataGenerator()->create_course();
+        $user = $this->getDataGenerator()->create_user();
+
+        // Make a quiz with an override.
+        $this->setUser();
+        $quiz = $this->create_test_quiz($course);
+        $DB->insert_record('quiz_overrides', [
+            'quiz' => $quiz->id,
+            'userid' => $user->id,
+            'timeclose' => 1300,
+            'timelimit' => null,
+        ]);
+
+        $cm = get_coursemodule_from_instance('quiz', $quiz->id);
+        $context = \context_module::instance($cm->id);
+
+        // Fetch the contexts - only one context should be returned.
+        $this->setUser();
+        $contextlist = provider::get_contexts_for_userid($user->id);
+        $this->assertCount(1, $contextlist);
+        $this->assertEquals($context, $contextlist->current());
+    }
+
     /**
      * The export function should handle an empty contextlist properly.
      */
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 7a094d1..469ef28 100644 (file)
@@ -184,17 +184,7 @@ class provider implements
             WHERE ctx.id = :contextid";
 
         $userlist->add_from_sql('userid', $sql, $params);
-
-        $sql = "
-          SELECT p.id
-            FROM {modules} m
-            JOIN {course_modules} cm ON cm.module = m.id AND m.name = :modname
-            JOIN {context} ctx ON ctx.instanceid = cm.id AND ctx.contextlevel = :contextlevel
-            JOIN {wiki_subwikis} s ON cm.instance = s.wikiid
-            JOIN {wiki_pages} p ON p.subwikiid = s.id
-            WHERE ctx.id = :contextid";
-
-        \core_comment\privacy\provider::get_users_in_context_from_sql($userlist, 'com', 'mod_wiki', 'wiki_page', $sql, $params);
+        \core_comment\privacy\provider::get_users_in_context_from_sql($userlist, 'com', 'mod_wiki', 'wiki_page', $context->id);
     }
 
     /**
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';