Merge branch 'MDL-47787-master' of https://github.com/tbannister/moodle

This commit is contained in:
Dan Poltawski 2015-06-30 09:52:52 +02:00
commit 1b80849856
5 changed files with 473 additions and 149 deletions

View File

@ -0,0 +1,103 @@
<?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 script fixes orphaned question categories.
*
* Orphaned question categories have had their associated context deleted
* but the category itself remains in the database with an invalid context.
*
* @package core
* @subpackage cli
* @copyright 2013 Tyler Bannister (tyler.bannister@remote-learner.net)
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
define('CLI_SCRIPT', true);
require(__DIR__.'/../../config.php');
require_once($CFG->libdir.'/clilib.php');
require_once($CFG->libdir.'/questionlib.php');
$long = array('fix' => false, 'help' => false);
$short = array('f' => 'fix', 'h' => 'help');
// Now get cli options.
list($options, $unrecognized) = cli_get_params($long, $short);
if ($unrecognized) {
$unrecognized = implode("\n ", $unrecognized);
cli_error(get_string('cliunknowoption', 'admin', $unrecognized));
}
if ($options['help']) {
$help =
"Fix orphaned question categories.
This scripts detects question categories that have had their
context deleted, thus severing them from their original purpose.
This script will find the orphaned categories and delete the unused
questions in each category found. Used questions will not be
deleted, instead they will be moved to a rescue question category.
Options:
-h, --help Print out this help
-f, --fix Fix the orphaned question categories in the DB.
If not specified only check and report problems to STDERR.
Example:
\$sudo -u www-data /usr/bin/php admin/cli/fix_orphaned_question_categories.php
\$sudo -u www-data /usr/bin/php admin/cli/fix_orphaned_question_categories.php -f
";
echo $help;
die;
}
cli_heading('Checking for orphaned categories');
$sql = 'SELECT qc.id, qc.contextid, qc.name
FROM {question_categories} qc
LEFT JOIN {context} c ON qc.contextid = c.id
WHERE c.id IS NULL';
$categories = $DB->get_recordset_sql($sql);
$i = 0;
foreach ($categories as $category) {
$i += 1;
echo "Found orphaned category: {$category->name}\n";
if (!empty($options['fix'])) {
echo "Cleaning...";
// One transaction per category.
$transaction = $DB->start_delegated_transaction();
question_category_delete_safe($category);
$transaction->allow_commit();
echo " Done!\n";
}
}
if (($i > 0) && !empty($options['fix'])) {
echo "Found and removed {$i} orphaned question categories\n";
} else if ($i > 0) {
echo "Found {$i} orphaned question categories. To fix, run:\n";
echo "\$sudo -u www-data /usr/bin/php admin/cli/fix_orphaned_question_categories.php --fix\n";
} else {
echo "No orphaned question categories found.\n";
}
$categories->close();

View File

@ -1633,7 +1633,7 @@ function set_coursemodule_visible($id, $visible) {
}
/**
* This function will handles the whole deletion process of a module. This includes calling
* This function will handle the whole deletion process of a module. This includes calling
* the modules delete_instance function, deleting files, events, grades, conditional data,
* the data in the course_module and course_sections table and adding a module deletion
* event to the DB.
@ -1645,9 +1645,10 @@ function course_delete_module($cmid) {
global $CFG, $DB;
require_once($CFG->libdir.'/gradelib.php');
require_once($CFG->libdir.'/questionlib.php');
require_once($CFG->dirroot.'/blog/lib.php');
require_once($CFG->dirroot.'/calendar/lib.php');
require_once($CFG->dirroot . '/tag/lib.php');
require_once($CFG->dirroot.'/tag/lib.php');
// Get the course module.
if (!$cm = $DB->get_record('course_modules', array('id' => $cmid))) {
@ -1679,6 +1680,9 @@ function course_delete_module($cmid) {
"Cannot delete this module as the function {$modulename}_delete_instance is missing in mod/$modulename/lib.php.");
}
// Delete activity context questions and question categories.
question_delete_activity($cm);
// Call the delete_instance function, if it returns false throw an exception.
if (!$deleteinstancefunction($cm->instance)) {
throw new moodle_exception('cannotdeletemoduleinstance', '', '', null,

View File

@ -539,7 +539,23 @@ class core_course_courselib_testcase extends advanced_testcase {
return $moduleinfo;
}
/**
* Data provider for course_delete module
*
* @return array An array of arrays contain test data
*/
public function provider_course_delete_module() {
$data = array();
$data['assign'] = array('assign', array('duedate' => time()));
$data['quiz'] = array('quiz', array('duedate' => time()));
return $data;
}
/**
* Test the create_course function
*/
public function test_create_course() {
global $DB;
$this->resetAfterTest(true);
@ -1476,51 +1492,92 @@ class core_course_courselib_testcase extends advanced_testcase {
$this->assertEquals($pagecm->visible, 0);
}
public function test_course_delete_module() {
/**
* Tests the function that deletes a course module
*
* @param string $type The type of module for the test
* @param array $options The options for the module creation
* @dataProvider provider_course_delete_module
*/
public function test_course_delete_module($type, $options) {
global $DB;
$this->resetAfterTest(true);
$this->setAdminUser();
// Create course and modules.
$course = $this->getDataGenerator()->create_course(array('numsections' => 5));
$options['course'] = $course->id;
// Generate an assignment with due date (will generate a course event).
$assign = $this->getDataGenerator()->create_module('assign', array('duedate' => time(), 'course' => $course->id));
$module = $this->getDataGenerator()->create_module($type, $options);
// Get the module context.
$modcontext = context_module::instance($assign->cmid);
$modcontext = context_module::instance($module->cmid);
// Verify context exists.
$this->assertInstanceOf('context_module', $modcontext);
// Add some tags to this assignment.
tag_set('assign', $assign->id, array('Tag 1', 'Tag 2', 'Tag 3'), 'mod_assign', $modcontext->id);
// Make module specific messes.
switch ($type) {
case 'assign':
// Add some tags to this assignment.
tag_set('assign', $module->id, array('Tag 1', 'Tag 2', 'Tag 3'), 'mod_assign', $modcontext->id);
// Confirm the tag instances were added.
$this->assertEquals(3, $DB->count_records('tag_instance', array('component' => 'mod_assign', 'contextid' =>
$modcontext->id)));
// Confirm the tag instances were added.
$criteria = array('component' => 'mod_assign', 'contextid' => $modcontext->id);
$this->assertEquals(3, $DB->count_records('tag_instance', $criteria));
// Verify event assignment event has been generated.
$eventcount = $DB->count_records('event', array('instance' => $assign->id, 'modulename' => 'assign'));
$this->assertEquals(1, $eventcount);
// Verify event assignment event has been generated.
$eventcount = $DB->count_records('event', array('instance' => $module->id, 'modulename' => $type));
$this->assertEquals(1, $eventcount);
break;
case 'quiz':
$qgen = $this->getDataGenerator()->get_plugin_generator('core_question');
$qcat = $qgen->create_question_category(array('contextid' => $modcontext->id));
$questions = array(
$qgen->create_question('shortanswer', null, array('category' => $qcat->id)),
$qgen->create_question('shortanswer', null, array('category' => $qcat->id)),
);
$this->expectOutputRegex('/'.get_string('unusedcategorydeleted', 'question').'/');
break;
default:
break;
}
// Run delete..
course_delete_module($assign->cmid);
course_delete_module($module->cmid);
// Verify the context has been removed.
$this->assertFalse(context_module::instance($assign->cmid, IGNORE_MISSING));
$this->assertFalse(context_module::instance($module->cmid, IGNORE_MISSING));
// Verify the course_module record has been deleted.
$cmcount = $DB->count_records('course_modules', array('id' => $assign->cmid));
$cmcount = $DB->count_records('course_modules', array('id' => $module->cmid));
$this->assertEmpty($cmcount);
// Verify event assignment events have been removed.
$eventcount = $DB->count_records('event', array('instance' => $assign->id, 'modulename' => 'assign'));
$this->assertEmpty($eventcount);
// Test clean up of module specific messes.
switch ($type) {
case 'assign':
// Verify event assignment events have been removed.
$eventcount = $DB->count_records('event', array('instance' => $module->id, 'modulename' => $type));
$this->assertEmpty($eventcount);
// Verify the tag instances were deleted.
$this->assertEquals(0, $DB->count_records('tag_instance', array('component' => 'mod_assign', 'contextid' =>
$modcontext->id)));
// Verify the tag instances were deleted.
$criteria = array('component' => 'mod_assign', 'contextid' => $modcontext->id);
$this->assertEquals(0, $DB->count_records('tag_instance', $criteria));
break;
case 'quiz':
// Verify category deleted.
$criteria = array('contextid' => $modcontext->id);
$this->assertEquals(0, $DB->count_records('question_categories', $criteria));
// Verify questions deleted.
$criteria = array('category' => $qcat->id);
$this->assertEquals(0, $DB->count_records('question', $criteria));
break;
default:
break;
}
}
/**

View File

@ -220,6 +220,52 @@ function match_grade_options($gradeoptionsfull, $grade, $matchgrades = 'error')
}
}
/**
* Category is about to be deleted,
* 1/ All questions are deleted for this question category.
* 2/ Any questions that can't be deleted are moved to a new category
* NOTE: this function is called from lib/db/upgrade.php
*
* @param object|coursecat $category course category object
*/
function question_category_delete_safe($category) {
global $DB;
$criteria = array('category' => $category->id);
$context = context::instance_by_id($category->contextid, IGNORE_MISSING);
$rescue = null; // See the code around the call to question_save_from_deletion.
// Deal with any questions in the category.
if ($questions = $DB->get_records('question', $criteria, '', 'id,qtype')) {
// Try to delete each question.
foreach ($questions as $question) {
question_delete_question($question->id);
}
// Check to see if there were any questions that were kept because
// they are still in use somehow, even though quizzes in courses
// in this category will already have been deleted. This could
// happen, for example, if questions are added to a course,
// and then that course is moved to another category (MDL-14802).
$questionids = $DB->get_records_menu('question', $criteria, '', 'id, 1');
if (!empty($questionids)) {
$parentcontextid = SYSCONTEXTID;
$name = get_string('unknown', 'question');
if ($context !== false) {
$name = $context->get_context_name();
$parentcontext = $context->get_parent_context();
if ($parentcontext) {
$parentcontextid = $parentcontext->id;
}
}
question_save_from_deletion(array_keys($questionids), $parentcontextid, $name, $rescue);
}
}
// Now delete the category.
$DB->delete_records('question_categories', array('id' => $category->id));
}
/**
* Tests whether any question in a category is used by any part of Moodle.
*
@ -305,6 +351,36 @@ function question_delete_question($questionid) {
question_bank::notify_question_edited($questionid);
}
/**
* All question categories and their questions are deleted for this context id.
*
* @param object $contextid The contextid to delete question categories from
* @return array Feedback from deletes (if any)
*/
function question_delete_context($contextid) {
global $DB;
//To store feedback to be showed at the end of the process
$feedbackdata = array();
//Cache some strings
$strcatdeleted = get_string('unusedcategorydeleted', 'question');
$fields = 'id, parent, name, contextid';
if ($categories = $DB->get_records('question_categories', array('contextid' => $contextid), 'parent', $fields)) {
//Sort categories following their tree (parent-child) relationships
//this will make the feedback more readable
$categories = sort_categories_by_tree($categories);
foreach ($categories as $category) {
question_category_delete_safe($category);
//Fill feedback
$feedbackdata[] = array($category->name, $strcatdeleted);
}
}
return $feedbackdata;
}
/**
* All question categories and their questions are deleted for this course.
*
@ -313,47 +389,15 @@ function question_delete_question($questionid) {
* @return boolean
*/
function question_delete_course($course, $feedback=true) {
global $DB, $OUTPUT;
//To store feedback to be showed at the end of the process
$feedbackdata = array();
//Cache some strings
$strcatdeleted = get_string('unusedcategorydeleted', 'question');
$coursecontext = context_course::instance($course->id);
$categoriescourse = $DB->get_records('question_categories',
array('contextid' => $coursecontext->id), 'parent', 'id, parent, name, contextid');
$feedbackdata = question_delete_context($coursecontext->id, $feedback);
if ($categoriescourse) {
//Sort categories following their tree (parent-child) relationships
//this will make the feedback more readable
$categoriescourse = sort_categories_by_tree($categoriescourse);
foreach ($categoriescourse as $category) {
//Delete it completely (questions and category itself)
//deleting questions
if ($questions = $DB->get_records('question',
array('category' => $category->id), '', 'id,qtype')) {
foreach ($questions as $question) {
question_delete_question($question->id);
}
$DB->delete_records("question", array("category" => $category->id));
}
//delete the category
$DB->delete_records('question_categories', array('id' => $category->id));
//Fill feedback
$feedbackdata[] = array($category->name, $strcatdeleted);
}
//Inform about changes performed if feedback is enabled
if ($feedback) {
$table = new html_table();
$table->head = array(get_string('category', 'question'), get_string('action'));
$table->data = $feedbackdata;
echo html_writer::table($table);
}
// Inform about changes performed if feedback is enabled.
if ($feedback && $feedbackdata) {
$table = new html_table();
$table->head = array(get_string('category', 'question'), get_string('action'));
$table->data = $feedbackdata;
echo html_writer::table($table);
}
return true;
}
@ -374,58 +418,10 @@ function question_delete_course_category($category, $newcategory, $feedback=true
$context = context_coursecat::instance($category->id);
if (empty($newcategory)) {
$feedbackdata = array(); // To store feedback to be showed at the end of the process
$rescueqcategory = null; // See the code around the call to question_save_from_deletion.
$strcatdeleted = get_string('unusedcategorydeleted', 'question');
// Loop over question categories.
if ($categories = $DB->get_records('question_categories',
array('contextid'=>$context->id), 'parent', 'id, parent, name')) {
foreach ($categories as $category) {
// Deal with any questions in the category.
if ($questions = $DB->get_records('question',
array('category' => $category->id), '', 'id,qtype')) {
// Try to delete each question.
foreach ($questions as $question) {
question_delete_question($question->id);
}
// Check to see if there were any questions that were kept because
// they are still in use somehow, even though quizzes in courses
// in this category will already have been deleted. This could
// happen, for example, if questions are added to a course,
// and then that course is moved to another category (MDL-14802).
$questionids = $DB->get_records_menu('question',
array('category'=>$category->id), '', 'id, 1');
if (!empty($questionids)) {
$parentcontextid = false;
$parentcontext = $context->get_parent_context();
if ($parentcontext) {
$parentcontextid = $parentcontext->id;
}
if (!$rescueqcategory = question_save_from_deletion(
array_keys($questionids), $parentcontextid,
$context->get_context_name(), $rescueqcategory)) {
return false;
}
$feedbackdata[] = array($category->name,
get_string('questionsmovedto', 'question', $rescueqcategory->name));
}
}
// Now delete the category.
if (!$DB->delete_records('question_categories', array('id'=>$category->id))) {
return false;
}
$feedbackdata[] = array($category->name, $strcatdeleted);
} // End loop over categories.
}
$feedbackdata = question_delete_context($context->id, $feedback);
// Output feedback if requested.
if ($feedback and $feedbackdata) {
if ($feedback && $feedbackdata) {
$table = new html_table();
$table->head = array(get_string('questioncategory', 'question'), get_string('action'));
$table->data = $feedbackdata;
@ -460,7 +456,7 @@ function question_delete_course_category($category, $newcategory, $feedback=true
* Enter description here...
*
* @param array $questionids of question ids
* @param object $newcontext the context to create the saved category in.
* @param object $newcontextid the context to create the saved category in.
* @param string $oldplace a textual description of the think being deleted,
* e.g. from get_context_name
* @param object $newcategory
@ -497,44 +493,16 @@ function question_save_from_deletion($questionids, $newcontextid, $oldplace,
* @return boolean
*/
function question_delete_activity($cm, $feedback=true) {
global $DB, $OUTPUT;
global $DB;
//To store feedback to be showed at the end of the process
$feedbackdata = array();
//Cache some strings
$strcatdeleted = get_string('unusedcategorydeleted', 'question');
$modcontext = context_module::instance($cm->id);
if ($categoriesmods = $DB->get_records('question_categories',
array('contextid' => $modcontext->id), 'parent', 'id, parent, name, contextid')) {
//Sort categories following their tree (parent-child) relationships
//this will make the feedback more readable
$categoriesmods = sort_categories_by_tree($categoriesmods);
foreach ($categoriesmods as $category) {
//Delete it completely (questions and category itself)
//deleting questions
if ($questions = $DB->get_records('question',
array('category' => $category->id), '', 'id,qtype')) {
foreach ($questions as $question) {
question_delete_question($question->id);
}
$DB->delete_records("question", array("category"=>$category->id));
}
//delete the category
$DB->delete_records('question_categories', array('id'=>$category->id));
//Fill feedback
$feedbackdata[] = array($category->name, $strcatdeleted);
}
//Inform about changes performed if feedback is enabled
if ($feedback) {
$table = new html_table();
$table->head = array(get_string('category', 'question'), get_string('action'));
$table->data = $feedbackdata;
echo html_writer::table($table);
}
$feedbackdata = question_delete_context($modcontext->id, $feedback);
// Inform about changes performed if feedback is enabled.
if ($feedback && $feedbackdata) {
$table = new html_table();
$table->head = array(get_string('category', 'question'), get_string('action'));
$table->data = $feedbackdata;
echo html_writer::table($table);
}
return true;
}
@ -1343,7 +1311,7 @@ function question_has_capability_on($question, $cap, $cachecat = -1) {
if (!isset($categories[$question->category])) {
if (!$categories[$question->category] = $DB->get_record('question_categories',
array('id'=>$question->category))) {
print_error('invalidcategory', 'quiz');
print_error('invalidcategory', 'question');
}
}
$category = $categories[$question->category];

View File

@ -28,6 +28,7 @@ defined('MOODLE_INTERNAL') || die();
global $CFG;
require_once($CFG->libdir . '/questionlib.php');
require_once($CFG->dirroot . '/mod/quiz/locallib.php');
require_once($CFG->dirroot . '/tag/lib.php');
// Get the necessary files to perform backup and restore.
@ -58,6 +59,61 @@ class core_questionlib_testcase extends advanced_testcase {
gc_collect_cycles();
}
/**
* Return true and false to test functions with feedback on and off.
*
* @return array Test data
*/
public function provider_feedback() {
return array(
'Feedback test' => array(true),
'No feedback test' => array(false)
);
}
/**
* Setup a course, a quiz, a question category and a question for testing.
*
* @param string $type The type of question category to create.
* @return array The created data objects
*/
public function setup_quiz_and_questions($type = 'module') {
// Create course category.
$category = $this->getDataGenerator()->create_category();
// Create course.
$course = $this->getDataGenerator()->create_course(array('numsections' => 5));
$options = array(
'course' => $course->id,
'duedate' => time(),
);
// Generate an assignment with due date (will generate a course event).
$quiz = $this->getDataGenerator()->create_module('quiz', $options);
$qgen = $this->getDataGenerator()->get_plugin_generator('core_question');
if ('course' == $type) {
$context = context_course::instance($course->id);
} else if ('category' == $type) {
$context = context_coursecat::instance($category->id);
} else {
$context = context_module::instance($quiz->cmid);
}
$qcat = $qgen->create_question_category(array('contextid' => $context->id));
$questions = array(
$qgen->create_question('shortanswer', null, array('category' => $qcat->id)),
$qgen->create_question('shortanswer', null, array('category' => $qcat->id)),
);
quiz_add_quiz_question($questions[0]->id, $quiz);
return array($category, $course, $quiz, $qcat, $questions);
}
public function test_question_reorder_qtypes() {
$this->assertEquals(
array(0 => 't2', 1 => 't1', 2 => 't3'),
@ -205,4 +261,140 @@ class core_questionlib_testcase extends advanced_testcase {
// Check that there are two questions in the restored to course's context.
$this->assertEquals(2, $DB->count_records('question', array('category' => $restoredcategory->id)));
}
/**
* This function tests the question_category_delete_safe function.
*/
public function test_question_category_delete_safe() {
global $DB;
$this->resetAfterTest(true);
$this->setAdminUser();
list($category, $course, $quiz, $qcat, $questions) = $this->setup_quiz_and_questions();
question_category_delete_safe($qcat);
// Verify category deleted.
$criteria = array('id' => $qcat->id);
$this->assertEquals(0, $DB->count_records('question_categories', $criteria));
// Verify questions deleted or moved.
$criteria = array('category' => $qcat->id);
$this->assertEquals(0, $DB->count_records('question', $criteria));
// Verify question not deleted.
$criteria = array('id' => $questions[0]->id);
$this->assertEquals(1, $DB->count_records('question', $criteria));
}
/**
* This function tests the question_delete_activity function.
*
* @param bool $feedback Whether to return feedback
* @dataProvider provider_feedback
*/
public function test_question_delete_activity($feedback) {
global $DB;
$this->resetAfterTest(true);
$this->setAdminUser();
list($category, $course, $quiz, $qcat, $questions) = $this->setup_quiz_and_questions();
$cm = get_coursemodule_from_instance('quiz', $quiz->id);
// Test that the feedback works.
if ($feedback) {
$this->expectOutputRegex('|'.get_string('unusedcategorydeleted', 'question').'|');
}
question_delete_activity($cm, $feedback);
// Verify category deleted.
$criteria = array('id' => $qcat->id);
$this->assertEquals(0, $DB->count_records('question_categories', $criteria));
// Verify questions deleted or moved.
$criteria = array('category' => $qcat->id);
$this->assertEquals(0, $DB->count_records('question', $criteria));
}
/**
* This function tests the question_delete_context function.
*/
public function test_question_delete_context() {
global $DB;
$this->resetAfterTest(true);
$this->setAdminUser();
list($category, $course, $quiz, $qcat, $questions) = $this->setup_quiz_and_questions();
// Get the module context id.
$result = question_delete_context($qcat->contextid);
// Verify category deleted.
$criteria = array('id' => $qcat->id);
$this->assertEquals(0, $DB->count_records('question_categories', $criteria));
// Verify questions deleted or moved.
$criteria = array('category' => $qcat->id);
$this->assertEquals(0, $DB->count_records('question', $criteria));
// Test that the feedback works.
$expected[] = array($qcat->name, get_string('unusedcategorydeleted', 'question'));
$this->assertEquals($expected, $result);
}
/**
* This function tests the question_delete_course function.
*
* @param bool $feedback Whether to return feedback
* @dataProvider provider_feedback
*/
public function test_question_delete_course($feedback) {
global $DB;
$this->resetAfterTest(true);
$this->setAdminUser();
list($category, $course, $quiz, $qcat, $questions) = $this->setup_quiz_and_questions('course');
// Test that the feedback works.
if ($feedback) {
$this->expectOutputRegex('|'.get_string('unusedcategorydeleted', 'question').'|');
}
question_delete_course($course, $feedback);
// Verify category deleted.
$criteria = array('id' => $qcat->id);
$this->assertEquals(0, $DB->count_records('question_categories', $criteria));
// Verify questions deleted or moved.
$criteria = array('category' => $qcat->id);
$this->assertEquals(0, $DB->count_records('question', $criteria));
}
/**
* This function tests the question_delete_course_category function.
*
* @param bool $feedback Whether to return feedback
* @dataProvider provider_feedback
*/
public function test_question_delete_course_category($feedback) {
global $DB;
$this->resetAfterTest(true);
$this->setAdminUser();
list($category, $course, $quiz, $qcat, $questions) = $this->setup_quiz_and_questions('category');
// Test that the feedback works.
if ($feedback) {
$this->expectOutputRegex('|'.get_string('unusedcategorydeleted', 'question').'|');
}
question_delete_course_category($category, 0, $feedback);
// Verify category deleted.
$criteria = array('id' => $qcat->id);
$this->assertEquals(0, $DB->count_records('question_categories', $criteria));
// Verify questions deleted or moved.
$criteria = array('category' => $qcat->id);
$this->assertEquals(0, $DB->count_records('question', $criteria));
}
}