Commit d44064cd authored by Andrew Nicols's avatar Andrew Nicols
Browse files

MDL-61407 question: Add initial privacy implementation

parent cc98914e
...@@ -386,6 +386,29 @@ $string['penaltyforeachincorrecttry_help'] = 'When questions are run using the \ ...@@ -386,6 +386,29 @@ $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.'; 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['previewquestion'] = 'Preview question: {$a}';
$string['privacy:metadata:database:question'] = 'The details about an 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.';
$string['privacy:metadata:database:question:name'] = 'The name of the question.';
$string['privacy:metadata:database:question:questiontext'] = 'The question text.';
$string['privacy:metadata:database:question:timecreated'] = 'The date and time when this question was created.';
$string['privacy:metadata:database:question:timemodified'] = 'The date and time when this question was updated.';
$string['privacy:metadata:database:question_attempt_step_data'] = 'Question attempt steps may have additional data specific to that step. The data is stored in the step_data table.';
$string['privacy:metadata:database:question_attempt_step_data:name'] = 'The name of the data item.';
$string['privacy:metadata:database:question_attempt_step_data:value'] = 'The value of the data item.';
$string['privacy:metadata:database:question_attempt_steps'] = 'Each question attempt has a number of steps to indicate the different phases from beginning to completion to marking. This table stores the information for each of these steps.';
$string['privacy:metadata:database:question_attempt_steps:fraction'] = 'The grade that was awarded to this question attempt scaled to a value out of 1.';
$string['privacy:metadata:database:question_attempt_steps:state'] = 'The state of this question attempt step at the end of the step transition.';
$string['privacy:metadata:database:question_attempt_steps:timecreated'] = 'The date and time that this step transition begun.';
$string['privacy:metadata:database:question_attempt_steps:userid'] = 'The user who performed the step transition.';
$string['privacy:metadata:database:question_attempts'] = 'The information about an attempt at a specific question.';
$string['privacy:metadata:database:question_attempts:flagged'] = 'An indication that the user has flagged this question within the attempt.';
$string['privacy:metadata:database:question_attempts:responsesummary'] = 'A summary of the question response.';
$string['privacy:metadata:database:question_attempts:timemodified'] = 'The time that the question attempt was updated.';
$string['privacy:metadata:link:qbehaviour'] = 'The Question subsystem makes use of the Question Behaviour plugintype.';
$string['privacy:metadata:link:qformat'] = 'The Question subsystem makes use of the Question Format plugintype for the purpose of importing and exporting questions in different formats.';
$string['privacy:metadata:link:qtype'] = 'The Question subsystem interacts with the Question Type plugintype which contains the different types of questions.';
$string['questionbehaviouradminsetting'] = 'Question behaviour settings'; $string['questionbehaviouradminsetting'] = 'Question behaviour settings';
$string['questionbehavioursdisabled'] = 'Question behaviours to disable'; $string['questionbehavioursdisabled'] = 'Question behaviours to disable';
$string['questionbehavioursdisabledexplained'] = 'Enter a comma separated list of behaviours you do not want to appear in dropdown menu'; $string['questionbehavioursdisabledexplained'] = 'Enter a comma separated list of behaviours you do not want to appear in dropdown menu';
...@@ -450,4 +473,3 @@ $string['whichtries'] = 'Which tries'; ...@@ -450,4 +473,3 @@ $string['whichtries'] = 'Which tries';
$string['withselected'] = 'With selected'; $string['withselected'] = 'With selected';
$string['xoutofmax'] = '{$a->mark} out of {$a->max}'; $string['xoutofmax'] = '{$a->mark} out of {$a->max}';
$string['yougotnright'] = 'You have correctly selected {$a->num}.'; $string['yougotnright'] = 'You have correctly selected {$a->num}.';
This diff is collapsed.
...@@ -87,6 +87,30 @@ class core_question_generator extends component_generator_base { ...@@ -87,6 +87,30 @@ class core_question_generator extends component_generator_base {
$question->category = $fromform->category; $question->category = $fromform->category;
$question->qtype = $qtype; $question->qtype = $qtype;
$question->createdby = 0; $question->createdby = 0;
return $this->update_question($question, $which, $overrides);
}
/**
* Update an existing question.
*
* @param stdClass $question the question data to update.
* @param string $which as for the corresponding argument of
* {@link question_test_helper::get_question_form_data}. null for the default one.
* @param array|stdClass $overrides any fields that should be different from the base example.
*/
public function update_question($question, $which = null, $overrides = null) {
global $CFG;
require_once($CFG->dirroot . '/question/engine/tests/helpers.php');
$qtype = $question->qtype;
$fromform = test_question_maker::get_question_form_data($qtype, $which);
$fromform = (object) $this->datagenerator->combine_defaults_and_record(
(array) $question, $fromform);
$fromform = (object) $this->datagenerator->combine_defaults_and_record(
(array) $fromform, $overrides);
return question_bank::get_qtype($qtype)->save_question($question, $fromform); return question_bank::get_qtype($qtype)->save_question($question, $fromform);
} }
......
<?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/>.
/**
* Helper for privacy tests.
*
* @package core_question
* @copyright 2018 Andrew Nicols <andrew@nicols.co.uk>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
defined('MOODLE_INTERNAL') || die();
use \core_privacy\local\request\writer;
/**
* Helper for privacy tests.
*
* @package core_question
* @copyright 2018 Andrew Nicols <andrew@nicols.co.uk>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
trait core_question_privacy_helper {
/**
* Assert that the question usage in the supplied slot matches the expected format
* and usage for a question.
*
* @param \question_usage_by_activity $quba The Question Usage to test against.
* @param int $slotno The slot number to compare
* @param \question_display_options $options The display options used for formatting.
* @param \stdClass $data The data to check.
*/
public function assert_question_slot_equals(
\question_usage_by_activity $quba,
$slotno,
\question_display_options $options,
$data
) {
$attempt = $quba->get_question_attempt($slotno);
$question = $attempt->get_question();
// Check the question data exported.
$this->assertEquals($data->name, $question->name);
$this->assertEquals($data->question, $question->questiontext);
// Check the answer exported.
$this->assertEquals($attempt->get_response_summary(), $data->answer);
if ($options->marks != \question_display_options::HIDDEN) {
$this->assertEquals($attempt->get_mark(), $data->mark);
} else {
$this->assertFalse(isset($data->mark));
}
if ($options->flags != \question_display_options::HIDDEN) {
$this->assertEquals($attempt->is_flagged(), (int) $data->flagged);
} else {
$this->assertFalse(isset($data->flagged));
}
if ($options->generalfeedback != \question_display_options::HIDDEN) {
$this->assertEquals($question->format_generalfeedback($attempt), $data->generalfeedback);
} else {
$this->assertFalse(isset($data->generalfeedback));
}
}
/**
* Assert that a question attempt was exported.
*
* @param \context $context The context which the attempt should be in
* @param array $subcontext The base of the export
* @param question_usage_by_activity $quba The question usage expected
* @param \question_display_options $options The display options used for formatting.
* @param \stdClass $user The user exported
*/
public function assert_question_attempt_exported(\context $context, array $subcontext, $quba, $options, $user) {
$usagecontext = array_merge(
$subcontext,
[get_string('questions', 'core_question')]
);
$writer = writer::with_context($context);
foreach ($quba->get_slots() as $slotno) {
$data = $writer->get_data(array_merge($usagecontext, [$slotno]));
$this->assert_question_slot_equals($quba, $slotno, $options, $data);
}
}
}
<?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/>.
/**
* Privacy provider tests.
*
* @package core_question
* @copyright 2018 Andrew Nicols <andrew@nicols.co.uk>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
use core_privacy\local\metadata\collection;
use core_privacy\local\request\deletion_criteria;
use core_privacy\local\request\writer;
use core_question\privacy\provider;
defined('MOODLE_INTERNAL') || die();
global $CFG;
require_once($CFG->libdir . '/xmlize.php');
require_once(__DIR__ . '/privacy_helper.php');
require_once(__DIR__ . '/../engine/tests/helpers.php');
/**
* Privacy provider tests class.
*
* @package core_question
* @copyright 2018 Andrew Nicols <andrew@nicols.co.uk>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class core_question_privacy_provider_testcase extends \core_privacy\tests\provider_testcase {
// Include the privacy helper which has assertions on it.
use core_question_privacy_helper;
/**
* Prepare a question attempt.
*
* @return question_usage_by_activity
*/
protected function prepare_question_attempt() {
// Create a question with a usage from the current user.
$questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question');
$cat = $questiongenerator->create_question_category();
$quba = question_engine::make_questions_usage_by_activity('core_question_preview', context_system::instance());
$quba->set_preferred_behaviour('deferredfeedback');
$questiondata = $questiongenerator->create_question('numerical', null, ['category' => $cat->id]);
$question = question_bank::load_question($questiondata->id);
$quba->add_question($question);
$quba->start_all_questions();
question_engine::save_questions_usage_by_activity($quba);
return $quba;
}
/**
* Test that calling export_question_usage on a usage belonging to a
* different user does not export any data.
*/
public function test_export_question_usage_no_usage() {
$this->resetAfterTest();
$quba = $this->prepare_question_attempt();
// Create a question with a usage from the current user.
$questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question');
$cat = $questiongenerator->create_question_category();
$quba = question_engine::make_questions_usage_by_activity('core_question_preview', context_system::instance());
$quba->set_preferred_behaviour('deferredfeedback');
$questiondata = $questiongenerator->create_question('numerical', null, ['category' => $cat->id]);
$question = question_bank::load_question($questiondata->id);
$quba->add_question($question);
$quba->start_all_questions();
question_engine::save_questions_usage_by_activity($quba);
// Set the user.
$testuser = $this->getDataGenerator()->create_user();
$this->setUser($testuser);
$context = $quba->get_owning_context();
$options = new \question_display_options();
provider::export_question_usage($testuser->id, $context, [], $quba->get_id(), $options, false);
$writer = writer::with_context($context);
$this->assertFalse($writer->has_any_data_in_any_context());
}
/**
* Test that calling export_question_usage on a usage belonging to a
* different user but ignoring the user match
*/
public function test_export_question_usage_with_usage() {
$this->resetAfterTest();
$quba = $this->prepare_question_attempt();
// Create a question with a usage from the current user.
$questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question');
$cat = $questiongenerator->create_question_category();
$quba = question_engine::make_questions_usage_by_activity('core_question_preview', context_system::instance());
$quba->set_preferred_behaviour('deferredfeedback');
$questiondata = $questiongenerator->create_question('truefalse', 'true', ['category' => $cat->id]);
$quba->add_question(question_bank::load_question($questiondata->id));
$questiondata = $questiongenerator->create_question('shortanswer', null, ['category' => $cat->id]);
$quba->add_question(question_bank::load_question($questiondata->id));
// Set the user and answer the questions.
$testuser = $this->getDataGenerator()->create_user();
$this->setUser($testuser);
$quba->start_all_questions();
$quba->process_action(1, ['answer' => 1]);
$quba->process_action(2, ['answer' => 'cat']);
$quba->finish_all_questions();
question_engine::save_questions_usage_by_activity($quba);
$context = $quba->get_owning_context();
// Export all questions for this attempt.
$options = new \question_display_options();
provider::export_question_usage($testuser->id, $context, [], $quba->get_id(), $options, true);
$writer = writer::with_context($context);
$this->assertTrue($writer->has_any_data_in_any_context());
$this->assertTrue($writer->has_any_data());
$slots = $quba->get_slots();
$this->assertCount(2, $slots);
foreach ($slots as $slotno) {
$data = $writer->get_data([get_string('questions', 'core_question'), $slotno]);
$this->assertNotNull($data);
$this->assert_question_slot_equals($quba, $slotno, $options, $data);
}
$this->assertEmpty($writer->get_data([get_string('questions', 'core_question'), $quba->next_slot_number()]));
// Disable some options and re-export.
writer::reset();
$options = new \question_display_options();
$options->hide_all_feedback();
$options->flags = \question_display_options::HIDDEN;
$options->marks = \question_display_options::HIDDEN;
provider::export_question_usage($testuser->id, $context, [], $quba->get_id(), $options, true);
$writer = writer::with_context($context);
$this->assertTrue($writer->has_any_data_in_any_context());
$this->assertTrue($writer->has_any_data());
$slots = $quba->get_slots();
$this->assertCount(2, $slots);
foreach ($slots as $slotno) {
$data = $writer->get_data([get_string('questions', 'core_question'), $slotno]);
$this->assertNotNull($data);
$this->assert_question_slot_equals($quba, $slotno, $options, $data);
}
$this->assertEmpty($writer->get_data([get_string('questions', 'core_question'), $quba->next_slot_number()]));
}
/**
* Test that questions owned by a user are exported and never deleted.
*/
public function test_question_owned_is_handled() {
global $DB;
$this->resetAfterTest();
$questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question');
// Create the two test users.
$user = $this->getDataGenerator()->create_user();
$otheruser = $this->getDataGenerator()->create_user();
// Create one question as each user in diferent contexts.
$this->setUser($user);
$userdata = $questiongenerator->setup_course_and_questions();
$expectedcontext = \context_course::instance($userdata[1]->id);
$this->setUser($otheruser);
$otheruserdata = $questiongenerator->setup_course_and_questions();
$unexpectedcontext = \context_course::instance($otheruserdata[1]->id);
// And create another one where we'll update a question as the test user.
$moreotheruserdata = $questiongenerator->setup_course_and_questions();
$otherexpectedcontext = \context_course::instance($moreotheruserdata[1]->id);
$morequestions = $moreotheruserdata[3];
// Update the third set of questions.
$this->setUser($user);
foreach ($morequestions as $question) {
$questiongenerator->update_question($question);
}
// Run the get_contexts_for_userid as default user.
$this->setUser();
// There should be two contexts returned - the first course, and the third.
$contextlist = provider::get_contexts_for_userid($user->id);
$this->assertCount(2, $contextlist);
$expectedcontexts = [
$expectedcontext->id,
$otherexpectedcontext->id,
];
$this->assertEquals($expectedcontexts, $contextlist->get_contextids(), 'Contexts not equal', 0.0, 10, true);
// Run the export_user_Data as the test user.
$this->setUser($user);
$approvedcontextlist = new \core_privacy\tests\request\approved_contextlist(
\core_user::get_user($user->id),
'core_question',
$expectedcontexts
);
provider::export_user_data($approvedcontextlist);
// There should be data for the user's question context.
$writer = writer::with_context($expectedcontext);
$this->assertTrue($writer->has_any_data());
// And for the course we updated.
$otherwriter = writer::with_context($otherexpectedcontext);
$this->assertTrue($otherwriter->has_any_data());
// But not for the other user's course.
$otherwriter = writer::with_context($unexpectedcontext);
$this->assertFalse($otherwriter->has_any_data());
// The question data is exported as an XML export in custom files.
$writer = writer::with_context($expectedcontext);
$subcontext = [get_string('questionbank', 'core_question')];
$exportfile = $writer->get_custom_file($subcontext, 'questions.xml');
$this->assertNotEmpty($exportfile);
$xmlized = xmlize($exportfile);
$xmlquestions = $xmlized['quiz']['#']['question'];
$this->assertCount(2, $xmlquestions);
// Run the delete functions as default user.
$this->setUser();
// The delete functions should do nothing here.
$this->assertCount(6, $DB->get_records('question'));
// Delete for all users in context.
provider::delete_data_for_all_users_in_context($expectedcontext);
$this->assertCount(6, $DB->get_records('question'));
provider::delete_data_for_user($approvedcontextlist);
$this->assertCount(6, $DB->get_records('question'));
}
/**
* Deleting questions should only unset their created and modified user.
*/
public function test_question_delete_data_for_user_anonymised() {
global $DB;
$this->resetAfterTest(true);
$user = \core_user::get_user_by_username('admin');
$otheruser = $this->getDataGenerator()->create_user();
$course = $this->getDataGenerator()->create_course();
$context = \context_course::instance($course->id);
$othercourse = $this->getDataGenerator()->create_course();
$othercontext = \context_course::instance($othercourse->id);
// Create a couple of questions.
$questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question');
$cat = $questiongenerator->create_question_category([
'contextid' => $context->id,
]);
$othercat = $questiongenerator->create_question_category([
'contextid' => $othercontext->id,
]);
// Create questions:
// Q1 - Created by the UUT, Modified by UUT.
// Q2 - Created by the UUT, Modified by the other user.
// Q3 - Created by the other user, Modified by UUT
// Q4 - Created by the other user, Modified by the other user.
// Q5 - Created by the UUT, Modified by the UUT, but in a different context.
$this->setUser($user);
$q1 = $questiongenerator->create_question('shortanswer', null, array('category' => $cat->id));
$q2 = $questiongenerator->create_question('shortanswer', null, array('category' => $cat->id));
$this->setUser($otheruser);
$questiongenerator->update_question($q2);
$q3 = $questiongenerator->create_question('shortanswer', null, array('category' => $cat->id));
$q4 = $questiongenerator->create_question('shortanswer', null, array('category' => $cat->id));
$this->setUser($user);
$questiongenerator->update_question($q3);
$q5 = $questiongenerator->create_question('shortanswer', null, array('category' => $othercat->id));
$approvedcontextlist = new \core_privacy\tests\request\approved_contextlist(
$user,
'core_question',
[$context->id]
);
// Delete the data and check it is removed.
$this->setUser();
provider::delete_data_for_user($approvedcontextlist);
$this->assertCount(5, $DB->get_records('question'));
$qrecord = $DB->get_record('question', ['id' => $q1->id]);
$this->assertEquals(0, $qrecord->createdby);
$this->assertEquals(0, $qrecord->modifiedby);
$qrecord = $DB->get_record('question', ['id' => $q2->id]);
$this->assertEquals(0, $qrecord->createdby);
$this->assertEquals($otheruser->id, $qrecord->modifiedby);
$qrecord = $DB->get_record('question', ['id' => $q3->id]);
$this->assertEquals($otheruser->id, $qrecord->createdby);
$this->assertEquals(0, $qrecord->modifiedby);
$qrecord = $DB->get_record('question', ['id' => $q4->id]);
$this->assertEquals($otheruser->id, $qrecord->createdby);
$this->assertEquals($otheruser->id, $qrecord->modifiedby);
$qrecord = $DB->get_record('question', ['id' => $q5->id]);
$this->assertEquals($user->id, $qrecord->createdby);
$this->assertEquals($user->id, $qrecord->modifiedby);
}
/**
* Deleting questions should only unset their created and modified user for all questions in a context.
*/
public function test_question_delete_data_for_all_users_in_context_anonymised() {
global $DB;
$this->resetAfterTest(true);
$user = \core_user::get_user_by_username('admin');
$otheruser = $this->getDataGenerator()->create_user();
$course = $this->getDataGenerator()->create_course();
$context = \context_course::instance($course->id);
$othercourse = $this->getDataGenerator()->create_course();
$othercontext = \context_course::instance($othercourse->id);
// Create a couple of questions.
$questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question');
$cat = $questiongenerator->create_question_category([
'contextid' => $context->id,
]);
$othercat = $questiongenerator->create_question_category([
'contextid' => $othercontext->id,
]);
// Create questions:
// Q1 - Created by the UUT, Modified by UUT.
// Q2 - Created by the UUT, Modified by the other user.
// Q3 - Created by the other user, Modified by UUT
// Q4 - Created by the other user, Modified by the other user.
// Q5 - Created by the UUT, Modified by the UUT, but in a different context.
$this->setUser($user);
$q1 = $questiongenerator->create_question('shortanswer', null, array('category' => $cat->id));
$q2 = $questiongenerator->create_question('shortanswer', null, array('category' => $cat->id));
$this->setUser($otheruser);
$questiongenerator->update_question($q2);
$q3 = $questiongenerator->create_question('shortanswer', null, array('category' => $cat->id));
$q4 = $questiongenerator->create_question('shortanswer', null, array('category' => $cat->id));
$this->setUser($user);
$questiongenerator->update_question($q3);
$q5 = $questiongenerator->create_question('shortanswer', null, array('category' => $othercat->id));
// Delete the data and check it is removed.
$this->setUser();
provider::delete_data_for_all_users_in_context($context);
$this->assertCount(5, $DB->get_records('question'));
$qrecord = $DB->get_record('question', ['id' => $q1->id]);
$this->assertEquals(0, $qrecord->createdby);
$this->assertEquals(0, $qrecord->modifiedby);
$qrecord = $DB->get_record('question', ['id' => $q2->id]);
$this->assertEquals(0, $qrecord->createdby);
$this->assertEquals(0, $qrecord->modifiedby);
$qrecord = $DB->get_record('question', ['id' => $q3->id]);
$this->assertEquals(0, $qrecord->createdby);
$this->assertEquals(0, $qrecord->modifiedby);
$qrecord = $DB->get_record('question', ['id' => $q4->id]);
$this->assertEquals(0, $qrecord->createdby);
$this->assertEquals(0, $qrecord->modifiedby);
$qrecord = $DB->get_record('question', ['id' => $q5->id]);
$this->assertEquals($user->id, $qrecord->createdby);
$this->assertEquals($user->id, $qrecord->modifiedby);
}
}