Merge branch 'MDL-43504-master' of git://github.com/andrewnicols/moodle
authorDamyon Wiese <damyon@moodle.com>
Mon, 17 Feb 2014 06:19:36 +0000 (14:19 +0800)
committerDamyon Wiese <damyon@moodle.com>
Mon, 17 Feb 2014 06:19:36 +0000 (14:19 +0800)
70 files changed:
admin/settings/appearance.php
backup/moodle2/backup_stepslib.php
backup/moodle2/restore_stepslib.php
cohort/locallib.php
grade/grading/form/rubric/tests/behat/behat_gradingform_rubric.php [new file with mode: 0644]
grade/grading/form/rubric/tests/behat/edit_rubric.feature [new file with mode: 0644]
grade/grading/form/rubric/tests/behat/publish_rubric_templates.feature [new file with mode: 0644]
grade/grading/form/rubric/tests/behat/reuse_own_rubrics.feature [new file with mode: 0644]
grade/grading/tests/behat/behat_grading.php [new file with mode: 0644]
grade/import/csv/index.php
lang/en/admin.php
lib/behat/classes/behat_selectors.php
lib/setuplib.php
lib/yui/build/moodle-core-dragdrop/moodle-core-dragdrop-debug.js
lib/yui/build/moodle-core-dragdrop/moodle-core-dragdrop-min.js
lib/yui/build/moodle-core-dragdrop/moodle-core-dragdrop.js
lib/yui/build/moodle-core-notification-dialogue/moodle-core-notification-dialogue-debug.js
lib/yui/build/moodle-core-notification-dialogue/moodle-core-notification-dialogue-min.js
lib/yui/build/moodle-core-notification-dialogue/moodle-core-notification-dialogue.js
lib/yui/build/moodle-core-tooltip/moodle-core-tooltip-debug.js
lib/yui/build/moodle-core-tooltip/moodle-core-tooltip-min.js
lib/yui/build/moodle-core-tooltip/moodle-core-tooltip.js
lib/yui/src/dragdrop/js/dragdrop.js
lib/yui/src/notification/js/dialogue.js
lib/yui/src/tooltip/js/tooltip.js
mod/assign/feedback/editpdf/yui/build/moodle-assignfeedback_editpdf-editor/moodle-assignfeedback_editpdf-editor-debug.js
mod/assign/feedback/editpdf/yui/build/moodle-assignfeedback_editpdf-editor/moodle-assignfeedback_editpdf-editor-min.js
mod/assign/feedback/editpdf/yui/build/moodle-assignfeedback_editpdf-editor/moodle-assignfeedback_editpdf-editor.js
mod/assign/feedback/editpdf/yui/src/editor/js/editor.js
mod/data/classes/event/course_module_instance_list_viewed.php [new file with mode: 0644]
mod/data/classes/event/course_module_viewed.php [new file with mode: 0644]
mod/data/classes/event/field_created.php [new file with mode: 0644]
mod/data/classes/event/field_deleted.php [new file with mode: 0644]
mod/data/classes/event/field_updated.php [new file with mode: 0644]
mod/data/classes/event/record_created.php [new file with mode: 0644]
mod/data/classes/event/record_deleted.php [new file with mode: 0644]
mod/data/classes/event/record_updated.php [new file with mode: 0644]
mod/data/classes/event/template_updated.php [new file with mode: 0644]
mod/data/classes/event/template_viewed.php [new file with mode: 0644]
mod/data/edit.php
mod/data/field.php
mod/data/index.php
mod/data/lang/en/data.php
mod/data/lib.php
mod/data/templates.php
mod/data/tests/events_test.php [new file with mode: 0644]
mod/data/view.php
mod/glossary/lib.php
mod/quiz/lib.php
question/format.php
question/format/xml/format.php
question/format/xml/tests/xmlformat_test.php
question/type/match/renderer.php
question/type/randomsamatch/backup/moodle1/lib.php [new file with mode: 0644]
question/type/randomsamatch/backup/moodle2/backup_qtype_randomsamatch_plugin.class.php
question/type/randomsamatch/backup/moodle2/restore_qtype_randomsamatch_plugin.class.php
question/type/randomsamatch/db/install.xml
question/type/randomsamatch/db/upgrade.php [new file with mode: 0644]
question/type/randomsamatch/db/upgradelib.php [new file with mode: 0644]
question/type/randomsamatch/edit_randomsamatch_form.php
question/type/randomsamatch/lang/en/qtype_randomsamatch.php
question/type/randomsamatch/lib.php [new file with mode: 0644]
question/type/randomsamatch/question.php [new file with mode: 0644]
question/type/randomsamatch/questiontype.php
question/type/randomsamatch/renderer.php [new file with mode: 0644]
question/type/randomsamatch/tests/helper.php [new file with mode: 0644]
question/type/randomsamatch/tests/question_test.php [new file with mode: 0644]
question/type/randomsamatch/tests/upgradelibnewqe_test.php [new file with mode: 0644]
question/type/randomsamatch/tests/walkthrough_test.php [new file with mode: 0644]
question/type/randomsamatch/version.php

index 2cf6f0f..49fb7ad 100644 (file)
@@ -191,6 +191,9 @@ if ($hassiteconfig or has_any_capability($capabilities, $systemcontext)) { // sp
     // "documentation" settingpage
     $temp = new admin_settingpage('documentation', new lang_string('moodledocs'));
     $temp->add(new admin_setting_configtext('docroot', new lang_string('docroot', 'admin'), new lang_string('configdocroot', 'admin'), 'http://docs.moodle.org', PARAM_URL));
+    $ltemp = array('' => get_string('forceno'));
+    $ltemp += get_string_manager()->get_list_of_translations(true);
+    $temp->add(new admin_setting_configselect('doclang', get_string('doclang', 'admin'), get_string('configdoclang', 'admin'), '', $ltemp));
     $temp->add(new admin_setting_configcheckbox('doctonewwindow', new lang_string('doctonewwindow', 'admin'), new lang_string('configdoctonewwindow', 'admin'), 0));
     $ADMIN->add('appearance', $temp);
 
index 4e79cc5..5ec6bad 100644 (file)
@@ -216,7 +216,7 @@ abstract class backup_questions_activity_structure_step extends backup_activity_
 
         $qas = new backup_nested_element($nameprefix . 'question_attempts');
         $qa = new backup_nested_element($nameprefix . 'question_attempt', array('id'), array(
-                'slot', 'behaviour', 'questionid', 'maxmark', 'minfraction', 'maxfraction',
+                'slot', 'behaviour', 'questionid', 'variant', 'maxmark', 'minfraction', 'maxfraction',
                 'flagged', 'questionsummary', 'rightanswer', 'responsesummary',
                 'timemodified'));
 
index 1045d94..68c24d6 100644 (file)
@@ -4069,6 +4069,9 @@ abstract class restore_questions_activity_structure_step extends restore_activit
 
         $data->questionusageid = $this->get_new_parentid($nameprefix . 'question_usage');
         $data->questionid      = $question->newitemid;
+        if (!property_exists($data, 'variant')) {
+            $data->variant = 1;
+        }
         $data->timemodified    = $this->apply_date_offset($data->timemodified);
 
         if (!property_exists($data, 'maxfraction')) {
index 23fa8a6..62dcfdf 100644 (file)
@@ -126,7 +126,7 @@ class cohort_existing_selector extends user_selector_base {
 
         if (!$this->is_validating()) {
             $potentialmemberscount = $DB->count_records_sql($countfields . $sql, $params);
-            if ($potentialmemberscount > 100) {
+            if ($potentialmemberscount > $this->maxusersperpage) {
                 return $this->too_many_results($search, $potentialmemberscount);
             }
         }
diff --git a/grade/grading/form/rubric/tests/behat/behat_gradingform_rubric.php b/grade/grading/form/rubric/tests/behat/behat_gradingform_rubric.php
new file mode 100644 (file)
index 0000000..bef83f9
--- /dev/null
@@ -0,0 +1,490 @@
+<?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/>.
+
+/**
+ * Steps definitions for rubrics.
+ *
+ * @package   gradingform_rubric
+ * @category  test
+ * @copyright 2013 David Monllaó
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+// NOTE: no MOODLE_INTERNAL test here, this file may be required by behat before including /config.php.
+
+require_once(__DIR__ . '/../../../../../../lib/behat/behat_base.php');
+
+use Behat\Gherkin\Node\TableNode as TableNode,
+    Behat\Behat\Context\Step\Given as Given,
+    Behat\Behat\Context\Step\When as When,
+    Behat\Behat\Context\Step\Then as Then,
+    Behat\Mink\Exception\ElementNotFoundException as ElementNotFoundException,
+    Behat\Mink\Exception\ExpectationException as ExpectationException;
+
+/**
+ * Steps definitions to help with rubrics.
+ *
+ * @package   gradingform_rubric
+ * @category  test
+ * @copyright 2013 David Monllaó
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class behat_gradingform_rubric extends behat_base {
+
+    /**
+     * @var The number of levels added by default when a rubric is created.
+     */
+    const DEFAULT_RUBRIC_LEVELS = 3;
+
+    /**
+     * Defines the rubric with the provided data, following rubric's definition grid cells.
+     *
+     * This method fills the rubric of the rubric definition
+     * form; the provided TableNode should contain one row for
+     * each criterion and each cell of the row should contain:
+     * # Criterion description
+     * # Criterion level 1 name
+     * # Criterion level 1 points
+     * # Criterion level 2 name
+     * # Criterion level 2 points
+     * # Criterion level 3 .....
+     *
+     * Works with both JS and non-JS.
+     *
+     * @When /^I define the following rubric:$/
+     * @throws ExpectationException
+     * @param TableNode $rubric
+     */
+    public function i_define_the_following_rubric(TableNode $rubric) {
+
+        // Being a smart method is nothing good when we talk about step definitions, in
+        // this case we didn't have any other options as there are no labels no elements
+        // id we can point to without having to "calculate" them.
+
+        $steptableinfo = '| criterion description | level1 name  | level1 points | level2 name | level2 points | ...';
+
+        $criteria = $rubric->getRows();
+
+        $addcriterionbutton = $this->find_button(get_string('addcriterion', 'gradingform_rubric'));
+
+        // Cleaning the current ones.
+        $deletebuttons = $this->find_all('css', "input[title='" . get_string('criteriondelete', 'gradingform_rubric') . "']");
+        if ($deletebuttons) {
+
+            // We should reverse the deletebuttons because otherwise once we delete
+            // the first one the DOM will change and the [X] one will not exist anymore.
+            $deletebuttons = array_reverse($deletebuttons, true);
+            foreach ($deletebuttons as $button) {
+                $this->click_and_confirm($button);
+            }
+        }
+
+        // The level number (NEWID$N) is not reset after each criterion.
+        $levelnumber = 1;
+
+        // The next criterion is created with the same number of levels than the last criterion.
+        $defaultnumberoflevels = self::DEFAULT_RUBRIC_LEVELS;
+
+        if ($criteria) {
+            foreach ($criteria as $criterionit => $criterion) {
+
+                // Checking the number of cells.
+                if (count($criterion) % 2 === 0) {
+                    throw new ExpectationException(
+                        'The criterion levels should contain both definition and points, follow this format:' . $steptableinfo,
+                        $this->getSession()
+                    );
+                }
+
+                // Minimum 2 levels per criterion.
+                // description + definition1 + score1 + definition2 + score2 = 5.
+                if (count($criterion) < 5) {
+                    throw new ExpectationException(
+                        get_string('err_mintwolevels', 'gradingform_rubric'),
+                        $this->getSession()
+                    );
+
+                }
+
+                // Add new criterion.
+                $addcriterionbutton->click();
+
+                $criterionroot = 'rubric[criteria][NEWID' . ($criterionit + 1) . ']';
+
+                // Getting the criterion description, this one is visible by default.
+                $this->set_rubric_field_value($criterionroot . '[description]', array_shift($criterion), true);
+
+                // When JS is disabled each criterion's levels name numbers starts from 0.
+                if (!$this->running_javascript()) {
+                    $levelnumber = 0;
+                }
+
+                // Setting the correct number of levels.
+                $nlevels = count($criterion) / 2;
+                if ($nlevels < $defaultnumberoflevels) {
+
+                    // Removing levels if there are too much levels.
+                    // When we add a new level the NEWID$N is increased from the last criterion.
+                    $lastcriteriondefaultlevel = $defaultnumberoflevels + $levelnumber - 1;
+                    $lastcriterionlevel = $nlevels + $levelnumber - 1;
+                    for ($i = $lastcriteriondefaultlevel; $i > $lastcriterionlevel; $i--) {
+
+                        // If JS is disabled seems that new levels are not added.
+                        if ($this->running_javascript()) {
+                            $deletelevel = $this->find_button($criterionroot . '[levels][NEWID' . $i . '][delete]');
+                            $this->click_and_confirm($deletelevel);
+
+                        } else {
+                            // Only if the level exists.
+                            $buttonname = $criterionroot . '[levels][NEWID' . $i . '][delete]';
+                            if ($deletelevel = $this->getSession()->getPage()->findButton($buttonname)) {
+                                $deletelevel->click();
+                            }
+                        }
+                    }
+                } else if ($nlevels > $defaultnumberoflevels) {
+                    // Adding levels if we don't have enough.
+                    $addlevel = $this->find_button($criterionroot . '[levels][addlevel]');
+                    for ($i = ($defaultnumberoflevels + 1); $i <= $nlevels; $i++) {
+                        $addlevel->click();
+                    }
+                }
+
+                // Updating it.
+                if ($nlevels > self::DEFAULT_RUBRIC_LEVELS) {
+                    $defaultnumberoflevels = $nlevels;
+                } else {
+                    // If it is less than the default value it sets it to
+                    // the default value.
+                    $defaultnumberoflevels = self::DEFAULT_RUBRIC_LEVELS;
+                }
+
+                foreach ($criterion as $i => $value) {
+
+                    $levelroot = $criterionroot . '[levels][NEWID' . $levelnumber . ']';
+
+                    if ($i % 2 === 0) {
+                        // Pairs are the definitions.
+                        $fieldname = $levelroot . '[definition]';
+                        $this->set_rubric_field_value($fieldname, $value);
+
+                    } else {
+                        // Odds are the points.
+
+                        // Checking it now, we would need to remove it if we are testing the form validations...
+                        if (!is_numeric($value)) {
+                            throw new ExpectationException(
+                                'The points cells should contain numeric values, follow this format: ' . $steptableinfo,
+                                $this->getSession()
+                            );
+                        }
+
+                        $fieldname = $levelroot . '[score]';
+                        $this->set_rubric_field_value($fieldname, $value, true);
+
+                        // Increase the level by one every 2 cells.
+                        $levelnumber++;
+                    }
+
+                }
+            }
+        }
+    }
+
+    /**
+     * Replaces a value from the specified criterion. You can use it when editing rubrics, to set both name or points.
+     *
+     * @When /^I replace "(?P<current_value_string>(?:[^"]|\\")*)" rubric level with "(?P<value_string>(?:[^"]|\\")*)" in "(?P<criterion_string>(?:[^"]|\\")*)" criterion$/
+     * @throws ElementNotFoundException
+     * @param string $currentvalue
+     * @param string $value
+     * @param string $criterionname
+     * @return Given[]
+     */
+    public function i_replace_rubric_level_with($currentvalue, $value, $criterionname) {
+
+        $currentvalueliteral = $this->getSession()->getSelectorsHandler()->xpathLiteral($currentvalue);
+        $criterionliteral = $this->getSession()->getSelectorsHandler()->xpathLiteral($criterionname);
+
+        $criterionxpath = "//div[@id='rubric-rubric']" .
+            "/descendant::td[contains(concat(' ', normalize-space(@class), ' '), ' description ')]";
+        // It differs between JS on/off.
+        if ($this->running_javascript()) {
+            $criterionxpath .= "/descendant::span[@class='textvalue'][text()=$criterionliteral]" .
+                "/ancestor::tr[contains(concat(' ', normalize-space(@class), ' '), ' criterion ')]";
+        } else {
+            $criterionxpath .= "/descendant::textarea[text()=$criterionliteral]" .
+                "/ancestor::tr[contains(concat(' ', normalize-space(@class), ' '), ' criterion ')]";
+        }
+
+        $inputxpath = $criterionxpath .
+            "/descendant::input[@type='text'][@value=$currentvalueliteral]";
+        $textareaxpath = $criterionxpath .
+            "/descendant::textarea[text()=$currentvalueliteral]";
+
+        if ($this->running_javascript()) {
+
+            $spansufix = "/ancestor::div[@class='level-wrapper']" .
+                "/descendant::div[@class='definition']" .
+                "/descendant::span[@class='textvalue']";
+
+            // Expanding the level input boxes.
+            $spannode = $this->find('xpath', $inputxpath . $spansufix . '|' . $textareaxpath . $spansufix);
+            $spannode->click();
+
+            $inputfield = $this->find('xpath', $inputxpath . '|' . $textareaxpath);
+            $inputfield->setValue($value);
+
+        } else {
+            $fieldnode = $this->find('xpath', $inputxpath . '|' . $textareaxpath);
+            $this->set_rubric_field_value($fieldnode->getAttribute('name'), $value);
+        }
+
+    }
+
+    /**
+     * Grades filling the current page rubric. Set one line per criterion and for each criterion set "| Criterion name | Points | Remark |".
+     *
+     * @When /^I grade by filling the rubric with:$/
+     *
+     * @throws ExpectationException
+     * @param TableNode $rubric
+     * @return void
+     */
+    public function i_grade_by_filling_the_rubric_with(TableNode $rubric) {
+
+        $criteria = $rubric->getRowsHash();
+
+        $stepusage = '"I grade by filling the rubric with:" step needs you to provide a table where each row is a criterion' .
+            ' and each criterion has 3 different values: | Criterion name | Number of points | Remark text |';
+
+        // To fill with the steps to execute.
+        $steps = array();
+
+        // First element -> name, second -> points, third -> Remark.
+        foreach ($criteria as $name => $criterion) {
+
+            // We only expect the points and the remark, as the criterion name is $name.
+            if (count($criterion) !== 2) {
+                throw new ExpectationException($stepusage, $this->getSession());
+            }
+
+            // Numeric value here.
+            $points = $criterion[0];
+            if (!is_numeric($points)) {
+                throw new ExpectationException($stepusage, $this->getSession());
+            }
+
+            // Selecting a value.
+            // When JS is disabled there are radio options, with JS enabled divs.
+            $selectedlevelxpath = $this->get_level_xpath($points);
+            if ($this->running_javascript()) {
+
+                // Only clicking on the selected level if it was not already selected.
+                $levelnode = $this->find('xpath', $selectedlevelxpath);
+
+                // Using in_array() as there are only a few elements.
+                if (!in_array('checked', explode(' ', $levelnode->getAttribute('class')))) {
+                    $steps[] = new Given('I click on "' . $selectedlevelxpath . '" "xpath_element" in the "' .
+                        $this->escape($name) . '" "table_row"');
+                }
+
+            } else {
+
+                // Getting the name of the field.
+                $radioxpath = $this->get_criterion_xpath($name) .
+                    $selectedlevelxpath . "/descendant::input[@type='radio']";
+                $radionode = $this->find('xpath', $radioxpath);
+                // TODO MDL-43738: Change setValue() to use the generic set_value()
+                // which will delegate the process to the field type.
+                $radionode->setValue($radionode->getAttribute('value'));
+            }
+
+            // Setting the remark.
+
+            // First we need to get the textarea name, then we can set the value.
+            $textarea = $this->get_node_in_container('css_element', 'textarea', 'table_row', $name);
+            $steps[] = new Given('I fill in "' . $textarea->getAttribute('name') . '" with "' . $criterion[1] . '"');
+        }
+
+        return $steps;
+    }
+
+    /**
+     * Checks that the level was previously selected and the user changed to another level.
+     *
+     * @Then /^the level with "(?P<points_number>\d+)" points was previously selected for the rubric criterion "(?P<criterion_name_string>(?:[^"]|\\")*)"$/
+     * @throws ExpectationException
+     * @param string $criterionname
+     * @param int $points
+     * @return void
+     */
+    public function the_level_with_points_was_previously_selected_for_the_rubric_criterion($points, $criterionname) {
+
+        $levelxpath = $this->get_criterion_xpath($criterionname) .
+            $this->get_level_xpath($points) .
+            "[contains(concat(' ', normalize-space(@class), ' '), ' currentchecked ')]";
+
+        // Works both for JS and non-JS.
+        // - JS: Class -> checked is there when is marked as green.
+        // - Non-JS: When editing a rubric definition, there are radio inputs and when viewing a
+        //   grade @class contains checked.
+        $levelxpath .= "[not(contains(concat(' ', normalize-space(@class), ' '), ' checked '))]" .
+            "[not(/descendant::input[@type='radio'][@checked!='checked'])]";
+
+        try {
+            $this->find('xpath', $levelxpath);
+        } catch (ElementNotFoundException $e) {
+            throw new ExpectationException('"' . $points . '" points level was not previously selected', $this->getSession());
+        }
+    }
+
+    /**
+     * Checks that the level is currently selected. Works both when grading rubrics and viewing graded rubrics.
+     *
+     * @Then /^the level with "(?P<points_number>\d+)" points is selected for the rubric criterion "(?P<criterion_name_string>(?:[^"]|\\")*)"$/
+     * @throws ExpectationException
+     * @param string $criterionname
+     * @param int $points
+     * @return void
+     */
+    public function the_level_with_points_is_selected_for_the_rubric_criterion($points, $criterionname) {
+
+        $levelxpath = $this->get_criterion_xpath($criterionname) .
+            $this->get_level_xpath($points);
+
+        // Works both for JS and non-JS.
+        // - JS: Class -> checked is there when is marked as green.
+        // - Non-JS: When editing a rubric definition, there are radio inputs and when viewing a
+        //   grade @class contains checked.
+        $levelxpath .= "[" .
+            "contains(concat(' ', normalize-space(@class), ' '), ' checked ')" .
+            " or " .
+            "/descendant::input[@type='radio'][@checked='checked']" .
+            "]";
+
+        try {
+            $this->find('xpath', $levelxpath);
+        } catch (ElementNotFoundException $e) {
+            throw new ExpectationException('"' . $points . '" points level is not selected', $this->getSession());
+        }
+    }
+
+    /**
+     * Checks that the level is not currently selected. Works both when grading rubrics and viewing graded rubrics.
+     *
+     * @Then /^the level with "(?P<points_number>\d+)" points is not selected for the rubric criterion "(?P<criterion_name_string>(?:[^"]|\\")*)"$/
+     * @throws ExpectationException
+     * @param string $criterionname
+     * @param int $points
+     * @return void
+     */
+    public function the_level_with_points_is_not_selected_for_the_rubric_criterion($points, $criterionname) {
+
+        $levelxpath = $this->get_criterion_xpath($criterionname) .
+            $this->get_level_xpath($points);
+
+        // Works both for JS and non-JS.
+        // - JS: Class -> checked is there when is marked as green.
+        // - Non-JS: When editing a rubric definition, there are radio inputs and when viewing a
+        //   grade @class contains checked.
+        $levelxpath .= "[not(contains(concat(' ', normalize-space(@class), ' '), ' checked '))]" .
+            "[./descendant::input[@type='radio'][@checked!='checked'] or not(./descendant::input[@type='radio'])]";
+
+        try {
+            $this->find('xpath', $levelxpath);
+        } catch (ElementNotFoundException $e) {
+            throw new ExpectationException('"' . $points . '" points level is selected', $this->getSession());
+        }
+    }
+
+
+    /**
+     * Makes a hidden rubric field visible (if necessary) and sets a value on it.
+     *
+     * @param string $name The name of the field
+     * @param string $value The value to set
+     * @param bool $visible
+     * @return void
+     */
+    protected function set_rubric_field_value($name, $value, $visible = false) {
+
+        // Fields are hidden by default.
+        if ($this->running_javascript() == true && $visible === false) {
+            $xpath = "//*[@name='$name']/following-sibling::*[contains(concat(' ', normalize-space(@class), ' '), ' plainvalue ')]";
+            $textnode = $this->find('xpath', $xpath);
+            $textnode->click();
+        }
+
+        // Set the value now.
+        $description = $this->find_field($name);
+        $description->setValue($value);
+    }
+
+    /**
+     * Performs click confirming the action.
+     *
+     * @param NodeElement $node
+     * @return void
+     */
+    protected function click_and_confirm($node) {
+
+        // Clicks to perform the action.
+        $node->click();
+
+        // Confirms the delete.
+        if ($this->running_javascript()) {
+            $confirmbutton = $this->get_node_in_container(
+                'button',
+                get_string('yes'),
+                'dialogue',
+                get_string('confirmation', 'admin')
+            );
+            $confirmbutton->click();
+        }
+    }
+
+    /**
+     * Returns the xpath representing a selected level.
+     *
+     * It is not including the path to the criterion.
+     *
+     * It is the xpath when grading a rubric or viewing a rubric,
+     * it is not the same xpath when editing a rubric.
+     *
+     * @param int $points
+     * @return string
+     */
+    protected function get_level_xpath($points) {
+        return "//td[contains(concat(' ', normalize-space(@class), ' '), ' level ')]" .
+            "[./descendant::span[@class='scorevalue'][text()='$points']]";
+    }
+
+    /**
+     * Returns the xpath representing the selected criterion.
+     *
+     * It is the xpath when grading a rubric or viewing a rubric,
+     * it is not the same xpath when editing a rubric.
+     *
+     * @param string $criterionname Literal including the criterion name.
+     * @return string
+     */
+    protected function get_criterion_xpath($criterionname) {
+        $literal = $this->getSession()->getSelectorsHandler()->xpathLiteral($criterionname);
+        return "//tr[contains(concat(' ', normalize-space(@class), ' '), ' criterion ')]" .
+            "[./descendant::td[@class='description'][text()=$literal]]";
+    }
+}
diff --git a/grade/grading/form/rubric/tests/behat/edit_rubric.feature b/grade/grading/form/rubric/tests/behat/edit_rubric.feature
new file mode 100644 (file)
index 0000000..2ff3a9a
--- /dev/null
@@ -0,0 +1,159 @@
+@gradingform @gradingform_rubric
+Feature: Rubrics can be created and edited
+  In order to use and refine rubrics to grade students
+  As a teacher
+  I need to edit previously used rubrics
+
+  Background:
+    Given the following "users" exists:
+      | username | firstname | lastname | email |
+      | teacher1 | Teacher | 1 | teacher1@asd.com |
+      | student1 | Student | 1 | student1@asd.com |
+    And the following "courses" exists:
+      | fullname | shortname | format |
+      | Course 1 | C1 | topics |
+    And the following "course enrolments" exists:
+      | user | course | role |
+      | teacher1 | C1 | editingteacher |
+      | student1 | C1 | student |
+    And I log in as "teacher1"
+    And I follow "Course 1"
+    And I turn editing mode on
+    And I add a "Assignment" to section "1" and I fill the form with:
+      | Assignment name | Test assignment 1 name |
+      | Description | Test assignment description |
+      | Grading method | Rubric |
+    When I go to "Test assignment 1 name" advanced grading definition page
+    # Defining a rubric.
+    And I fill the moodle form with:
+      | Name | Assignment 1 rubric |
+      | Description | Rubric test description |
+    And I define the following rubric:
+      | TMP Criterion 1 | TMP Level 11 | 11 | TMP Level 12 | 12 |
+      | TMP Criterion 2 | TMP Level 21 | 21 | TMP Level 22 | 22 |
+      | TMP Criterion 3 | TMP Level 31 | 31 | TMP Level 32 | 32 |
+      | TMP Criterion 4 | TMP Level 41 | 41 | TMP Level 42 | 42 |
+    # Checking that only the last ones are saved.
+    And I define the following rubric:
+      | Criterion 1 | Level 11 | 1 | Level 12 | 20 | Level 13 | 40 | Level 14 | 50 |
+      | Criterion 2 | Level 21 | 10 | Level 22 | 20 | Level 23 | 30 |
+      | Criterion 3 | Level 31 | 5 | Level 32 | 20 |
+    And I press "Save as draft"
+    And I go to "Test assignment 1 name" advanced grading definition page
+    And I click on "Move down" "button" in the "Criterion 1" "table_row"
+    And I press "Save rubric and make it ready"
+    Then I should see "Ready for use"
+    # Grading two students.
+    And I go to "Student 1" "Test assignment 1 name" activity advanced grading page
+    And I grade by filling the rubric with:
+      | Criterion 1 | 50 | Very good |
+    And I press "Save changes"
+    # Checking that it complains if you don't select a level for each criterion.
+    And I should see "Please choose something for each criterion"
+    And I grade by filling the rubric with:
+      | Criterion 1 | 50 | Very good |
+      | Criterion 2 | 10 | Mmmm, you can do it better |
+      | Criterion 3 | 5 | Not good |
+    And I complete the advanced grading form with these values:
+      | Feedback comments | In general... work harder... |
+    # Checking that the user grade is correct.
+    And I should see "58.33" in the "Student 1" "table_row"
+    # Updating the user grade.
+    And I go to "Student 1" "Test assignment 1 name" activity advanced grading page
+    And I grade by filling the rubric with:
+      | Criterion 1 | 20 | Bad, I changed my mind |
+      | Criterion 2 | 10 | Mmmm, you can do it better |
+      | Criterion 3 | 5 | Not good |
+    #And the level with "50" points was previously selected for the rubric criterion "Criterion 1"
+    #And the level with "20" points is selected for the rubric criterion "Criterion 1"
+    And I save the advanced grading form
+    And I should see "22.62" in the "Student 1" "table_row"
+    And I log out
+    # Viewing it as a student.
+    And I log in as "student1"
+    And I follow "Course 1"
+    And I follow "Test assignment 1 name"
+    And I should see "22.62" in the ".feedback" "css_element"
+    And I should see "Rubric test description" in the ".feedback" "css_element"
+    And I should see "In general... work harder..."
+    And the level with "10" points is selected for the rubric criterion "Criterion 2"
+    And the level with "20" points is selected for the rubric criterion "Criterion 1"
+    And the level with "5" points is selected for the rubric criterion "Criterion 3"
+    And I log out
+    And I log in as "teacher1"
+    And I follow "Course 1"
+    # Editing a rubric definition without regrading students.
+    And I go to "Test assignment 1 name" advanced grading definition page
+    And "Save as draft" "button" should not exists
+    And I click on "Move up" "button" in the "Criterion 1" "table_row"
+    And I replace "Level 11" rubric level with "Level 11 edited" in "Criterion 1" criterion
+    And I press "Save"
+    And I should see "You are about to save changes to a rubric that has already been used for grading."
+    And I select "Do not mark for regrade" from "menurubricregrade"
+    And I press "Continue"
+    And I log out
+    # Check that the student still sees the grade.
+    And I log in as "student1"
+    And I follow "Course 1"
+    And I follow "Test assignment 1 name"
+    And I should see "22.62" in the ".feedback" "css_element"
+    And the level with "20" points is selected for the rubric criterion "Criterion 1"
+    And I log out
+    # Editing a rubric with significant changes.
+    And I log in as "teacher1"
+    And I follow "Course 1"
+    And I go to "Test assignment 1 name" advanced grading definition page
+    And I click on "Move down" "button" in the "Criterion 2" "table_row"
+    And I replace "1" rubric level with "11" in "Criterion 1" criterion
+    And I press "Save"
+    And I should see "You are about to save significant changes to a rubric that has already been used for grading. The gradebook value will be unchanged, but the rubric will be hidden from students until their item is regraded."
+    And I press "Continue"
+    And I log out
+    # Check that the student doesn't see the grade.
+    And I log in as "student1"
+    And I follow "Course 1"
+    And I follow "Test assignment 1 name"
+    And I should see "22.62" in the ".feedback" "css_element"
+    And the level with "20" points is not selected for the rubric criterion "Criterion 1"
+    And I log out
+    # Regrade student.
+    And I log in as "teacher1"
+    And I follow "Course 1"
+    And I follow "Test assignment 1 name"
+    And I go to "Student 1" "Test assignment 1 name" activity advanced grading page
+    And I should see "The rubric definition was changed after this student had been graded. The student can not see this rubric until you check the rubric and update the grade."
+    And I save the advanced grading form
+    And I log out
+    # Check that the student sees the grade again.
+    And I log in as "student1"
+    And I follow "Course 1"
+    And I follow "Test assignment 1 name"
+    And I should see "12.16" in the ".feedback" "css_element"
+    And the level with "20" points is not selected for the rubric criterion "Criterion 1"
+    # Hide all rubric info for students
+    And I log out
+    And I log in as "teacher1"
+    And I follow "Course 1"
+    And I go to "Test assignment 1 name" advanced grading definition page
+    And I uncheck "Allow users to preview rubric used in the module (otherwise rubric will only become visible after grading)"
+    And I uncheck "Display rubric description during evaluation"
+    And I uncheck "Display rubric description to those being graded"
+    And I uncheck "Display points for each level during evaluation"
+    And I uncheck "Display points for each level to those being graded"
+    And I press "Save"
+    And I select "Do not mark for regrade" from "menurubricregrade"
+    And I press "Continue"
+    And I log out
+    # Students should not see anything.
+    And I log in as "student1"
+    And I follow "Course 1"
+    And I follow "Test assignment 1 name"
+    And I should not see "Criterion 1" in the ".submissionstatustable" "css_element"
+    And I should not see "Criterion 2" in the ".submissionstatustable" "css_element"
+    And I should not see "Criterion 3" in the ".submissionstatustable" "css_element"
+    And I should not see "Rubric test description" in the ".feedback" "css_element"
+
+  @javascript
+  Scenario: I can use rubrics to grade and edit them later updating students grades with Javascript enabled
+
+  Scenario: I can use rubrics to grade and edit them later updating students grades with Javascript disabled
diff --git a/grade/grading/form/rubric/tests/behat/publish_rubric_templates.feature b/grade/grading/form/rubric/tests/behat/publish_rubric_templates.feature
new file mode 100644 (file)
index 0000000..302a12f
--- /dev/null
@@ -0,0 +1,56 @@
+@gradingform @gradingform_rubric
+Feature: Publish rubrics as templates
+  In order to save time to teachers
+  As a manager
+  I need to publish rubrics and make them available to all teachers
+
+  Background:
+    Given the following "users" exists:
+      | username | firstname | lastname | email |
+      | teacher1 | Teacher | 1 | teacher1@asd.com |
+      | manager1 | Manager | 1 | manager1@asd.com |
+    And the following "courses" exists:
+      | fullname | shortname | format |
+      | Course 1 | C1 | topics |
+    And the following "activities" exists:
+      | activity | course | idnumber | name                   | intro | advancedgradingmethod_submissions |
+      | assign   | C1     | A1       | Test assignment 1 name | TA1   | rubric                            |
+      | assign   | C1     | A2       | Test assignment 2 name | TA2   | rubric                            |
+    And the following "course enrolments" exists:
+      | user | course | role |
+      | teacher1 | C1 | editingteacher |
+    And the following "system role assigns" exists:
+      | user | role | contextlevel | reference |
+      | manager1 | manager | System | |
+    And I log in as "manager1"
+    And I follow "Course 1"
+    And I go to "Test assignment 1 name" advanced grading definition page
+    And I fill the moodle form with:
+      | Name | Assignment 1 rubric |
+      | Description | Assignment 1 description |
+    And I define the following rubric:
+      | Criterion 1 | Level 11 | 11 | Level 12 | 12 |
+      | Criterion 2 | Level 21 | 21 | Level 22 | 22 |
+    And I press "Save rubric and make it ready"
+    When I publish "Test assignment 1 name" grading form definition as a public template
+    And I log out
+    And I log in as "teacher1"
+    And I follow "Course 1"
+    And I set "Test assignment 2 name" activity to use "Assignment 1 rubric" grading form
+    Then I should see "Advanced grading: Test assignment 2 name (Submissions)"
+    And I should see "Criterion 1"
+    And I should see "Assignment 1 description"
+    And I go to "Test assignment 2 name" advanced grading definition page
+    And I should see "Current rubric status"
+
+  @javascript
+  Scenario: Create a rubric template and reuse it as a teacher, with Javascript enabled
+    Then the "Description" field should match "<p>Assignment 1 description</p>" value
+    And I should see "Criterion 1"
+    And I press "Cancel"
+
+  Scenario: Create a rubric template and reuse it as a teacher, with Javascript disabled
+    Then the "Description" field should match "Assignment 1 description" value
+    # Trying to avoid pointing by id or name as the code internals may change.
+    And "//table[@class='criteria']//textarea[text()='Criterion 1']" "xpath_element" should exists
+    And I press "Cancel"
diff --git a/grade/grading/form/rubric/tests/behat/reuse_own_rubrics.feature b/grade/grading/form/rubric/tests/behat/reuse_own_rubrics.feature
new file mode 100644 (file)
index 0000000..0c3799c
--- /dev/null
@@ -0,0 +1,52 @@
+@gradingform @gradingform_rubric
+Feature: Reuse my rubrics in other activities
+  In order to save time creating duplicated grading forms
+  As a teacher
+  I need to reuse rubrics that I created previously
+
+  Background:
+    Given the following "users" exists:
+      | username | firstname | lastname | email |
+      | teacher1 | Teacher | 1 | teacher1@asd.com |
+    And the following "courses" exists:
+      | fullname | shortname | format |
+      | Course 1 | C1 | topics |
+    And the following "course enrolments" exists:
+      | user | course | role |
+      | teacher1 | C1 | editingteacher |
+    And I log in as "teacher1"
+    And I follow "Course 1"
+    And I turn editing mode on
+    And I add a "Assignment" to section "1" and I fill the form with:
+      | Assignment name | Test assignment 1 name |
+      | Description | Test assignment 1 description |
+      | Grading method | Rubric |
+    And I go to "Test assignment 1 name" advanced grading definition page
+    And I fill the moodle form with:
+      | Name | Assignment 1 rubric |
+      | Description | Assignment 1 description |
+    And I define the following rubric:
+      | Criterion 1 | Level 11 | 11 | Level 12 | 12 | Level 3 | 13 |
+      | Criterion 2 | Level 21 | 21 | Level 22 | 22 | Level 3 | 23 |
+      | Criterion 3 | Level 31 | 31 | Level 32 | 32 |
+    And I press "Save rubric and make it ready"
+    And I follow "Course 1"
+    When I add a "Assignment" to section "1" and I fill the form with:
+      | Assignment name | Test assignment 2 name |
+      | Description | Test assignment 2 description |
+      | Grading method | Rubric |
+    And I set "Test assignment 2 name" activity to use "Assignment 1 rubric" grading form
+    Then I should see "Ready for use"
+    And I should see "Criterion 1"
+    And I should see "Criterion 2"
+    And I should see "Criterion 3"
+    And I go to "Test assignment 1 name" advanced grading definition page
+    And I should see "Criterion 1"
+    And I should see "Criterion 2"
+    And I should see "Criterion 3"
+    And I press "Cancel"
+
+  @javascript
+  Scenario: A teacher can reuse one of his/her previously created rubrics, with Javascript enabled
+
+  Scenario: A teacher can reuse one of his/her previously created rubrics, with Javascript disabled
diff --git a/grade/grading/tests/behat/behat_grading.php b/grade/grading/tests/behat/behat_grading.php
new file mode 100644 (file)
index 0000000..3c60cd6
--- /dev/null
@@ -0,0 +1,181 @@
+<?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/>.
+
+/**
+ * Grading methods steps definitions.
+ *
+ * @package   core_grading
+ * @category  test
+ * @copyright 2013 David Monllaó
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+// NOTE: no MOODLE_INTERNAL test here, this file may be required by behat before including /config.php.
+
+require_once(__DIR__ . '/../../../../lib/behat/behat_base.php');
+
+use Behat\Gherkin\Node\TableNode as TableNode,
+    Behat\Behat\Context\Step\Given as Given,
+    Behat\Behat\Context\Step\When as When;
+
+/**
+ * Generic grading methods step definitions.
+ *
+ * @package   core_grading
+ * @category  test
+ * @copyright 2013 David Monllaó
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class behat_grading extends behat_base {
+
+    /**
+     * Goes to the selected advanced grading page. You should be in the course page when this step begins.
+     *
+     * @Given /^I go to "(?P<activity_name_string>(?:[^"]|\\")*)" advanced grading page$/
+     * @param string $activityname
+     * @return Given[]
+     */
+    public function i_go_to_advanced_grading_page($activityname) {
+        return array(
+            new Given('I follow "' . $this->escape($activityname) . '"'),
+            new Given('I follow "' . get_string('gradingmanagement', 'grading') . '"'),
+        );
+    }
+
+    /**
+     * Goes to the selected advanced grading definition page. You should be in the course page when this step begins.
+     *
+     * @Given /^I go to "(?P<activity_name_string>(?:[^"]|\\")*)" advanced grading definition page$/
+     * @param string $activityname
+     * @return Given[]
+     */
+    public function i_go_to_advanced_grading_definition_page($activityname) {
+
+        // Transforming to literals, probably not necessary, just in case.
+        $newactionliteral = $this->getSession()->getSelectorsHandler()->xpathLiteral(get_string("manageactionnew", "grading"));
+        $editactionliteral = $this->getSession()->getSelectorsHandler()->xpathLiteral(get_string("manageactionedit", "grading"));
+
+        // Working both when adding and editing.
+        $definitionxpath = "//a[@class='action']" .
+            "[./descendant::*[contains(., $newactionliteral) or contains(., $editactionliteral)]]";
+
+        return array(
+            new Given('I go to "' . $this->escape($activityname) . '" advanced grading page'),
+            new Given('I click on "' . $this->escape($definitionxpath) . '" "xpath_element"'),
+        );
+    }
+    /**
+     * Goes to the student's advanced grading page.
+     *
+     * @Given /^I go to "(?P<user_fullname_string>(?:[^"]|\\")*)" "(?P<activity_name_string>(?:[^"]|\\")*)" activity advanced grading page$/
+     * @param string $userfullname The user full name including firstname and lastname.
+     * @param string $activityname The activity name
+     * @return Given[]
+     */
+    public function i_go_to_activity_advanced_grading_page($userfullname, $activityname) {
+
+        // Step to access the user grade page from the grading page.
+        $usergradetext = get_string('gradeuser', 'assign', $userfullname);
+        $gradeuserstep = new Given('I follow "' . $this->escape($usergradetext) . '"');
+
+        // Shortcut in case we already are in the grading page.
+        $usergradetextliteral = $this->getSession()->getSelectorsHandler()->xpathLiteral($usergradetext);
+        if ($this->getSession()->getPage()->find('named', array('link', $usergradetextliteral))) {
+            return $gradeuserstep;
+        }
+
+        return array(
+            new Given('I follow "' . $this->escape($activityname) . '"'),
+            new Given('I follow "' . $this->escape(get_string('viewgrading', 'assign')) . '"'),
+            $gradeuserstep
+        );
+    }
+
+    /**
+     * Publishes current activity grading defined form as a public template.
+     *
+     * @Given /^I publish "(?P<activity_name_string>(?:[^"]|\\")*)" grading form definition as a public template$/
+     * @param string $activityname
+     * @return Given[]
+     */
+    public function i_publish_grading_form_definition_as_a_public_template($activityname) {
+
+        return array(
+            new Given('I go to "' . $this->escape($activityname) . '" advanced grading page'),
+            new Given('I click on "' . $this->escape(get_string("manageactionshare", "grading")) . '" "link"'),
+            new Given('I press "' . get_string('continue') . '"')
+        );
+    }
+
+    /**
+     * Sets a previously created grading form as the activity grading form.
+     *
+     * @Given /^I set "(?P<activity_name_string>(?:[^"]|\\")*)" activity to use "(?P<grading_form_template_string>(?:[^"]|\\")*)" grading form$/
+     * @param string $activityname
+     * @param string $templatename
+     * @return Given[]
+     */
+    public function i_set_activity_to_use_grading_form($activityname, $templatename) {
+
+        $templateliteral = $this->getSession()->getSelectorsHandler()->xpathLiteral($templatename);
+
+        $templatexpath = "//h2[@class='template-name'][contains(., $templateliteral)]/" .
+            "following-sibling::div[contains(concat(' ', normalize-space(@class), ' '), ' template-actions ')]";
+
+        // Should work with both templates and own forms.
+        $literaltemplate = $this->getSession()->getSelectorsHandler()->xpathLiteral(get_string('templatepick', 'grading'));
+        $literalownform = $this->getSession()->getSelectorsHandler()->xpathLiteral(get_string('templatepickownform', 'grading'));
+        $usetemplatexpath = "//a[./descendant::div[text()=$literaltemplate]]|" .
+            "//a[./descendant::div[text()=$literalownform]]";
+
+        return array(
+            new Given('I go to "' . $this->escape($activityname) . '" advanced grading page'),
+            new Given('I follow "' . $this->escape(get_string('manageactionclone', 'grading')) . '"'),
+            new Given('I check "' . get_string('searchownforms', 'grading') . '"'),
+            new Given('I click on "' . get_string('search') . '" "button" in the "region-main" "region"'),
+            new Given('I click on "' . $this->escape($usetemplatexpath) . '" "xpath_element" ' .
+                'in the "' . $this->escape($templatexpath) . '" "xpath_element"'),
+            new Given('I press "' . get_string('continue') . '"')
+        );
+    }
+
+    /**
+     * Saves the current page advanced grading form.
+     *
+     * @When /^I save the advanced grading form$/
+     * @return When[]
+     */
+    public function i_save_the_advanced_grading_form() {
+        return array(
+            new When('I press "' . get_string('savechanges') . '"'),
+            new When('I press "' . get_string('continue') . '"')
+        );
+    }
+
+    /**
+     * Grades an activity using advanced grading. Note the grade is set by other steps, depending on the grading method.
+     *
+     * @Given /^I complete the advanced grading form with these values:$/
+     * @param TableNode $data
+     * @return Given[]
+     */
+    public function i_complete_the_advanced_grading_form_with_these_values(TableNode $data) {
+        return array(
+            new Given('I fill the moodle form with:', $data),
+            new Given('I save the advanced grading form')
+        );
+    }
+}
index 07d6e2e..990764c 100644 (file)
@@ -235,7 +235,7 @@ if ($formdata = $mform2->get_data()) {
                     $studentid = $value;
                 break;
                 case 'useridnumber':
-                    if (!$user = $DB->get_record('user', array('idnumber' => $value))) {
+                    if (empty($value) || !$user = $DB->get_record('user', array('idnumber' => $value))) {
                          // user not found, abort whole import
                         import_cleanup($importcode);
                         echo $OUTPUT->notification("user mapping error, could not find user with idnumber \"$value\"");
index 2ef3ae1..c414ca6 100644 (file)
@@ -192,6 +192,7 @@ $string['configenabledevicedetection'] = 'Enables detection of mobiles, smartpho
 $string['configdisableuserimages'] = 'Disable the ability for users to change user profile images.';
 $string['configdisplayloginfailures'] = 'This will display information to selected users about previous failed logins.';
 $string['configdndallowtextandlinks'] = 'Enable or disable the dragging and dropping of text and links onto a course page, alongside the dragging and dropping of files. Note that the dragging of text into Firefox or between different browsers is unreliable and may result in no data being uploaded, or corrupted text being uploaded.';
+$string['configdoclang'] = 'This language will be used in links for the documentation pages.';
 $string['configdocroot'] = 'Defines the path to the Moodle Docs for providing context-specific documentation via \'Moodle Docs for this page\' links in the footer of each page. If the field is left blank, links will not be displayed.';
 $string['configdoctonewwindow'] = 'If you enable this, then links to Moodle Docs will be shown in a new window.';
 $string['configeditordictionary'] = 'This value will be used if aspell doesn\'t have dictionary for users own language.';
@@ -429,6 +430,7 @@ $string['disableuserimages'] = 'Disable user profile images';
 $string['displayerrorswarning'] = 'Enabling the PHP setting <em>display_errors</em> is not recommended on production sites because some error messages may reveal sensitive information about your server.';
 $string['displayloginfailures'] = 'Display login failures to';
 $string['dndallowtextandlinks'] = 'Drag and drop upload of text/links';
+$string['doclang'] = 'Language for docs';
 $string['docroot'] = 'Moodle Docs document root';
 $string['doctonewwindow'] = 'Open in new window';
 $string['download'] = 'Download';
index b0153d9..48688b0 100644 (file)
@@ -83,10 +83,10 @@ class behat_selectors {
      */
     protected static $moodleselectors = array(
         'dialogue' => <<<XPATH
-.//div[contains(concat(' ', normalize-space(@class), ' '), ' moodle-dialogue ')]/descendant::h1[normalize-space(.) = %locator%]/ancestor::div[contains(concat(' ', normalize-space(@class), ' '), ' moodle-dialogue ')]
+//div[contains(concat(' ', normalize-space(@class), ' '), ' moodle-dialogue ')]/descendant::h1[normalize-space(.) = %locator%]/ancestor::div[contains(concat(' ', normalize-space(@class), ' '), ' moodle-dialogue ')] | //div[contains(concat(' ', normalize-space(@class), ' '), ' yui-dialog ')]/descendant::div[@class='hd'][normalize-space(.) = %locator%]/parent::div
 XPATH
         , 'block' => <<<XPATH
-.//div[contains(concat(' ', normalize-space(@class), ' '), concat(' ', %locator%, ' '))] | .//div[contains(concat(' ', normalize-space(@class), ' '), ' block ')]/descendant::h2[normalize-space(.) = %locator%]/ancestor::div[contains(concat(' ', normalize-space(@class), ' '), ' block ')]
+//div[contains(concat(' ', normalize-space(@class), ' '), concat(' ', %locator%, ' '))] | //div[contains(concat(' ', normalize-space(@class), ' '), ' block ')]/descendant::h2[normalize-space(.) = %locator%]/ancestor::div[contains(concat(' ', normalize-space(@class), ' '), ' block ')]
 XPATH
         , 'region' => <<<XPATH
 .//*[self::div | self::section | self::aside][./@id = %locator%]
index 799e53d..25f456b 100644 (file)
@@ -629,10 +629,16 @@ function get_docs_url($path = null) {
         // that will ensure people end up at the latest version of the docs.
         $branch = '.';
     }
-    if (!empty($CFG->docroot)) {
-        return $CFG->docroot . '/' . $branch . '/' . current_language() . '/' . $path;
+    if (empty($CFG->doclang)) {
+        $lang = current_language();
     } else {
-        return 'http://docs.moodle.org/'. $branch . '/' . current_language() . '/' . $path;
+        $lang = $CFG->doclang;
+    }
+    $end = '/' . $branch . '/' . $lang . '/' . $path;
+    if (empty($CFG->docroot)) {
+        return 'http://docs.moodle.org'. $end;
+    } else {
+        return $CFG->docroot . $end ;
     }
 }
 
index 9a609a5..af66f37 100644 (file)
Binary files a/lib/yui/build/moodle-core-dragdrop/moodle-core-dragdrop-debug.js and b/lib/yui/build/moodle-core-dragdrop/moodle-core-dragdrop-debug.js differ
index 54e334f..991fbbb 100644 (file)
Binary files a/lib/yui/build/moodle-core-dragdrop/moodle-core-dragdrop-min.js and b/lib/yui/build/moodle-core-dragdrop/moodle-core-dragdrop-min.js differ
index 9a609a5..af66f37 100644 (file)
Binary files a/lib/yui/build/moodle-core-dragdrop/moodle-core-dragdrop.js and b/lib/yui/build/moodle-core-dragdrop/moodle-core-dragdrop.js differ
index 2e4bc5d..2abf031 100644 (file)
Binary files a/lib/yui/build/moodle-core-notification-dialogue/moodle-core-notification-dialogue-debug.js and b/lib/yui/build/moodle-core-notification-dialogue/moodle-core-notification-dialogue-debug.js differ
index 313aa1c..340f083 100644 (file)
Binary files a/lib/yui/build/moodle-core-notification-dialogue/moodle-core-notification-dialogue-min.js and b/lib/yui/build/moodle-core-notification-dialogue/moodle-core-notification-dialogue-min.js differ
index 27a544a..6035f54 100644 (file)
Binary files a/lib/yui/build/moodle-core-notification-dialogue/moodle-core-notification-dialogue.js and b/lib/yui/build/moodle-core-notification-dialogue/moodle-core-notification-dialogue.js differ
index 03408fe..74972f7 100644 (file)
Binary files a/lib/yui/build/moodle-core-tooltip/moodle-core-tooltip-debug.js and b/lib/yui/build/moodle-core-tooltip/moodle-core-tooltip-debug.js differ
index c31276b..7697ff3 100644 (file)
Binary files a/lib/yui/build/moodle-core-tooltip/moodle-core-tooltip-min.js and b/lib/yui/build/moodle-core-tooltip/moodle-core-tooltip-min.js differ
index 03408fe..74972f7 100644 (file)
Binary files a/lib/yui/build/moodle-core-tooltip/moodle-core-tooltip.js and b/lib/yui/build/moodle-core-tooltip/moodle-core-tooltip.js differ
index a77fe23..00a8e9f 100644 (file)
@@ -536,6 +536,15 @@ Y.extend(DRAGDROP, Y.Base, {
         // Simulate the full sequence.
         this.drag_start(dragevent);
         this.global_drop_over(dropevent);
+
+        if (droptarget.hasClass(this.parentnodeclass) && droptarget.contains(dragcontainer)) {
+            // The global_drop_over function does not handle the case where an item was moved up, without the
+            // 'goingup' variable being set, as is the case wih keyboard drag/drop. We must detect this case and
+            // apply it after the drop_over, but before the drop_hit event in order for it to be moved to the
+            // correct location.
+            droptarget.prepend(dragcontainer);
+        }
+
         this.global_drop_hit(dropevent);
     },
 
index 2f3424a..bc684e4 100644 (file)
@@ -74,7 +74,7 @@ Y.extend(DIALOGUE, Y.Panel, {
      * @method initializer
      * @return void
      */
-    initializer : function(config) {
+    initializer : function() {
         var bb;
 
         if (this.get('render')) {
@@ -82,7 +82,7 @@ Y.extend(DIALOGUE, Y.Panel, {
         }
         this.makeResponsive();
         this.after('visibleChange', this.visibilityChanged, this);
-        if (config.center) {
+        if (this.get('center')) {
             this.centerDialogue();
         }
         this.set('COUNT', COUNT);
@@ -95,10 +95,10 @@ Y.extend(DIALOGUE, Y.Panel, {
         // and allow setting of z-index in theme.
         bb = this.get('boundingBox');
 
-        if (config.extraClasses) {
-            Y.Array.each(config.extraClasses, bb.addClass, bb);
-        }
-        if (config.visible) {
+        // Add any additional classes that were specified.
+        Y.Array.each(this.get('extraClasses'), bb.addClass, bb);
+
+        if (this.get('visible')) {
             this.applyZIndex();
         }
         // Recalculate the zIndex every time the modal is altered.
@@ -107,7 +107,7 @@ Y.extend(DIALOGUE, Y.Panel, {
         // either by centerDialogue or makeResonsive. This is because the show() will trigger
         // a focus on the dialogue, which will scroll the page. If the dialogue has not
         // been positioned it will scroll back to the top of the page.
-        if (config.visible) {
+        if (this.get('visible')) {
             this.show();
             this.keyDelegation();
         }
@@ -309,7 +309,15 @@ Y.extend(DIALOGUE, Y.Panel, {
         return result;
     },
 
-    hide: function() {
+    hide: function(e) {
+        if (e) {
+            // If the event was closed by an escape key event, then we need to check that this
+            // dialogue is currently focused to prevent closing all dialogues in the stack.
+            if (e.type === 'key' && e.keyCode === 27 && !this.get('focused')) {
+                return;
+            }
+        }
+
         // Unlock scroll if the plugin is present.
         if (this.lockScroll) {
             this.lockScroll.disableScrollLock();
@@ -536,6 +544,17 @@ Y.Base.modifyAttrs(DIALOGUE, {
     render : {
         value : true,
         writeOnce : true
+    },
+
+    /**
+     * Any additional classes to add to the boundingBox.
+     *
+     * @attributes extraClasses
+     * @type Array
+     * @default []
+     */
+    extraClasses: {
+        value: []
     }
 });
 
index 1812cb4..f89bbf5 100644 (file)
@@ -422,7 +422,7 @@ Y.extend(TOOLTIP, M.core.dialogue, {
 
     close_panel: function(e) {
         // Hide the panel first.
-        this.hide();
+        this.hide(e);
 
         // Cancel the listeners that we added in display_panel.
         this.cancel_events();
index e00100a..7b06b6f 100644 (file)
Binary files a/mod/assign/feedback/editpdf/yui/build/moodle-assignfeedback_editpdf-editor/moodle-assignfeedback_editpdf-editor-debug.js and b/mod/assign/feedback/editpdf/yui/build/moodle-assignfeedback_editpdf-editor/moodle-assignfeedback_editpdf-editor-debug.js differ
index 066f2e0..651bbd9 100644 (file)
Binary files a/mod/assign/feedback/editpdf/yui/build/moodle-assignfeedback_editpdf-editor/moodle-assignfeedback_editpdf-editor-min.js and b/mod/assign/feedback/editpdf/yui/build/moodle-assignfeedback_editpdf-editor/moodle-assignfeedback_editpdf-editor-min.js differ
index e00100a..7b06b6f 100644 (file)
Binary files a/mod/assign/feedback/editpdf/yui/build/moodle-assignfeedback_editpdf-editor/moodle-assignfeedback_editpdf-editor.js and b/mod/assign/feedback/editpdf/yui/build/moodle-assignfeedback_editpdf-editor/moodle-assignfeedback_editpdf-editor.js differ
index 7989a7a..5d10701 100644 (file)
@@ -288,6 +288,7 @@ EDITOR.prototype = {
                 headerContent: this.get('header'),
                 bodyContent: this.get('body'),
                 footerContent: this.get('footer'),
+                modal: true,
                 width: '840px',
                 visible: false,
                 draggable: true
diff --git a/mod/data/classes/event/course_module_instance_list_viewed.php b/mod/data/classes/event/course_module_instance_list_viewed.php
new file mode 100644 (file)
index 0000000..7e19721
--- /dev/null
@@ -0,0 +1,32 @@
+<?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/>.
+
+/**
+ * The mod_data course module instance list viewed event.
+ *
+ * @package    mod_data
+ * @copyright  2014 Mark Nelson <markn@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace mod_data\event;
+
+defined('MOODLE_INTERNAL') || die();
+
+class course_module_instance_list_viewed extends \core\event\course_module_instance_list_viewed {
+    // No code required here as the parent class handles it all.
+}
+
diff --git a/mod/data/classes/event/course_module_viewed.php b/mod/data/classes/event/course_module_viewed.php
new file mode 100644 (file)
index 0000000..14358e2
--- /dev/null
@@ -0,0 +1,41 @@
+<?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/>.
+
+/**
+ * The mod_data course module viewed event.
+ *
+ * @package    mod_data
+ * @copyright  2014 Mark Nelson <markn@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace mod_data\event;
+
+defined('MOODLE_INTERNAL') || die();
+
+class course_module_viewed extends \core\event\course_module_viewed {
+
+    /**
+     * Init method.
+     *
+     * @return void
+     */
+    protected function init() {
+        $this->data['objecttable'] = 'data';
+        $this->data['crud'] = 'r';
+        $this->data['edulevel'] = self::LEVEL_PARTICIPATING;
+    }
+}
diff --git a/mod/data/classes/event/field_created.php b/mod/data/classes/event/field_created.php
new file mode 100644 (file)
index 0000000..e479697
--- /dev/null
@@ -0,0 +1,94 @@
+<?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/>.
+
+/**
+ * The mod_data field created event.
+ *
+ * @property-read array $other {
+ *      Extra information about event.
+ *
+ *      @type string fieldname the name of the field.
+ *      @type int dataid the id of the data activity.
+ * }
+ *
+ * @package    mod_data
+ * @copyright  2014 Mark Nelson <markn@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace mod_data\event;
+
+defined('MOODLE_INTERNAL') || die();
+
+class field_created extends \core\event\base {
+
+    /**
+     * Init method.
+     *
+     * @return void
+     */
+    protected function init() {
+        $this->data['objecttable'] = 'data_fields';
+        $this->data['crud'] = 'c';
+        $this->data['edulevel'] = self::LEVEL_TEACHING;
+    }
+
+    /**
+     * Return localised event name.
+     *
+     * @return string
+     */
+    public static function get_name() {
+        return get_string('eventfieldcreated', 'mod_data');
+    }
+
+    /**
+     * Returns description of what happened.
+     *
+     * @return string
+     */
+    public function get_description() {
+        return 'The field ' . $this->objectid . ' belonging to the data activity ' . $this->other['dataid'] . ' has been created.';
+    }
+
+    /**
+     * Get the legacy event log data.
+     *
+     * @return array
+     */
+    public function get_legacy_logdata() {
+        return array($this->courseid, 'data', 'fields add', 'field.php?d=' . $this->other['dataid'] . '&amp;mode=display&amp;fid=' .
+            $this->objectid, $this->objectid, $this->contextinstanceid);
+    }
+
+    /**
+     * Custom validation.
+     *
+     * @throws \coding_exception when validation does not pass.
+     * @return void
+     */
+    protected function validate_data() {
+        parent::validate_data();
+
+        if (!isset($this->other['fieldname'])) {
+            throw new \coding_exception('The fieldname must be set in $other.');
+        }
+
+        if (!isset($this->other['dataid'])) {
+            throw new \coding_exception('The dataid must be set in $other.');
+        }
+    }
+}
diff --git a/mod/data/classes/event/field_deleted.php b/mod/data/classes/event/field_deleted.php
new file mode 100644 (file)
index 0000000..8004537
--- /dev/null
@@ -0,0 +1,94 @@
+<?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/>.
+
+/**
+ * The mod_data field deleted event.
+ *
+ * @property-read array $other {
+ *      Extra information about event.
+ *
+ *      @type string fieldname the name of the field.
+ *      @type int dataid the id of the data activity.
+ * }
+ *
+ * @package    mod_data
+ * @copyright  2014 Mark Nelson <markn@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace mod_data\event;
+
+defined('MOODLE_INTERNAL') || die();
+
+class field_deleted extends \core\event\base {
+
+    /**
+     * Init method.
+     *
+     * @return void
+     */
+    protected function init() {
+        $this->data['objecttable'] = 'data_fields';
+        $this->data['crud'] = 'd';
+        $this->data['edulevel'] = self::LEVEL_TEACHING;
+    }
+
+    /**
+     * Return localised event name.
+     *
+     * @return string
+     */
+    public static function get_name() {
+        return get_string('eventfielddeleted', 'mod_data');
+    }
+
+    /**
+     * Returns description of what happened.
+     *
+     * @return string
+     */
+    public function get_description() {
+        return 'The field ' . $this->objectid . ' belonging to the data activity ' . $this->other['dataid'] . ' has been deleted.';
+    }
+
+    /**
+     * Get the legacy event log data.
+     *
+     * @return array
+     */
+    public function get_legacy_logdata() {
+        return array($this->courseid, 'data', 'fields delete', 'field.php?d=' . $this->other['dataid'],
+            $this->other['fieldname'], $this->contextinstanceid);
+    }
+
+    /**
+     * Custom validation.
+     *
+     * @throws \coding_exception when validation does not pass.
+     * @return void
+     */
+    protected function validate_data() {
+        parent::validate_data();
+
+        if (!isset($this->other['fieldname'])) {
+            throw new \coding_exception('The fieldname must be set in $other.');
+        }
+
+        if (!isset($this->other['dataid'])) {
+            throw new \coding_exception('The dataid must be set in $other.');
+        }
+    }
+}
diff --git a/mod/data/classes/event/field_updated.php b/mod/data/classes/event/field_updated.php
new file mode 100644 (file)
index 0000000..eb80890
--- /dev/null
@@ -0,0 +1,94 @@
+<?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/>.
+
+/**
+ * The mod_data field updated event.
+ *
+ * @property-read array $other {
+ *      Extra information about event.
+ *
+ *      @type string fieldname the name of the field.
+ *      @type int dataid the id of the data activity.
+ * }
+ *
+ * @package    mod_data
+ * @copyright  2014 Mark Nelson <markn@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace mod_data\event;
+
+defined('MOODLE_INTERNAL') || die();
+
+class field_updated extends \core\event\base {
+
+    /**
+     * Init method.
+     *
+     * @return void
+     */
+    protected function init() {
+        $this->data['objecttable'] = 'data_fields';
+        $this->data['crud'] = 'u';
+        $this->data['edulevel'] = self::LEVEL_TEACHING;
+    }
+
+    /**
+     * Return localised event name.
+     *
+     * @return string
+     */
+    public static function get_name() {
+        return get_string('eventfieldupdated', 'mod_data');
+    }
+
+    /**
+     * Returns description of what happened.
+     *
+     * @return string
+     */
+    public function get_description() {
+        return 'The field ' . $this->objectid . ' belonging to the data activity ' . $this->other['dataid'] . ' has been updated.';
+    }
+
+    /**
+     * Get the legacy event log data.
+     *
+     * @return array
+     */
+    public function get_legacy_logdata() {
+        return array($this->courseid, 'data', 'fields update', 'field.php?d=' . $this->other['dataid'] .
+            '&amp;mode=display&amp;fid=' . $this->objectid, $this->objectid, $this->contextinstanceid);
+    }
+
+    /**
+     * Custom validation.
+     *
+     * @throws \coding_exception when validation does not pass.
+     * @return void
+     */
+    protected function validate_data() {
+        parent::validate_data();
+
+        if (!isset($this->other['fieldname'])) {
+            throw new \coding_exception('The fieldname must be set in $other.');
+        }
+
+        if (!isset($this->other['dataid'])) {
+            throw new \coding_exception('The dataid must be set in $other.');
+        }
+    }
+}
diff --git a/mod/data/classes/event/record_created.php b/mod/data/classes/event/record_created.php
new file mode 100644 (file)
index 0000000..5cbfe26
--- /dev/null
@@ -0,0 +1,90 @@
+<?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/>.
+
+/**
+ * The mod_data data record created event.
+ *
+ * @property-read array $other {
+ *      Extra information about event.
+ *
+ *      @type int dataid the id of the data activity.
+ * }
+ *
+ * @package    mod_data
+ * @copyright  2014 Mark Nelson <markn@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace mod_data\event;
+
+defined('MOODLE_INTERNAL') || die();
+
+class record_created extends \core\event\base {
+
+    /**
+     * Init method.
+     *
+     * @return void
+     */
+    protected function init() {
+        $this->data['objecttable'] = 'data_records';
+        $this->data['crud'] = 'c';
+        $this->data['edulevel'] = self::LEVEL_TEACHING;
+    }
+
+    /**
+     * Return localised event name.
+     *
+     * @return string
+     */
+    public static function get_name() {
+        return get_string('eventrecordcreated', 'mod_data');
+    }
+
+    /**
+     * Returns description of what happened.
+     *
+     * @return string
+     */
+    public function get_description() {
+        return 'The data record ' . $this->objectid . ' belonging to the database activity ' . $this->other['dataid'] .
+            ' was created by the user ' . $this->userid;
+    }
+
+    /**
+     * Get the legacy event log data.
+     *
+     * @return array
+     */
+    public function get_legacy_logdata() {
+        return array($this->courseid, 'data', 'add', 'view.php?d=' . $this->other['dataid'] . '&amp;rid=' . $this->objectid,
+            $this->other['dataid'], $this->contextinstanceid);
+    }
+
+    /**
+     * Custom validation.
+     *
+     * @throws \coding_exception when validation does not pass.
+     * @return void
+     */
+    protected function validate_data() {
+        parent::validate_data();
+
+        if (!isset($this->other['dataid'])) {
+            throw new \coding_exception('The dataid must be set in $other.');
+        }
+    }
+}
diff --git a/mod/data/classes/event/record_deleted.php b/mod/data/classes/event/record_deleted.php
new file mode 100644 (file)
index 0000000..f611d9a
--- /dev/null
@@ -0,0 +1,90 @@
+<?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/>.
+
+/**
+ * The mod_data record deleted event.
+ *
+ * @property-read array $other {
+ *      Extra information about event.
+ *
+ *      @type int dataid the id of the data activity.
+ * }
+ *
+ * @package    mod_data
+ * @copyright  2014 Mark Nelson <markn@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace mod_data\event;
+
+defined('MOODLE_INTERNAL') || die();
+
+class record_deleted extends \core\event\base {
+
+    /**
+     * Init method.
+     *
+     * @return void
+     */
+    protected function init() {
+        $this->data['objecttable'] = 'data_records';
+        $this->data['crud'] = 'd';
+        $this->data['edulevel'] = self::LEVEL_TEACHING;
+    }
+
+    /**
+     * Return localised event name.
+     *
+     * @return string
+     */
+    public static function get_name() {
+        return get_string('eventrecorddeleted', 'mod_data');
+    }
+
+    /**
+     * Returns description of what happened.
+     *
+     * @return string
+     */
+    public function get_description() {
+        return 'The data record ' . $this->objectid . ' belonging to the database activity ' . $this->other['dataid'] .
+            ' was deleted by the user ' . $this->userid;
+    }
+
+    /**
+     * Get the legacy event log data.
+     *
+     * @return array
+     */
+    public function get_legacy_logdata() {
+        return array($this->courseid, 'data', 'record delete', 'view.php?id=' . $this->contextinstanceid,
+            $this->other['dataid'], $this->contextinstanceid);
+    }
+
+    /**
+     * Custom validation.
+     *
+     * @throws \coding_exception when validation does not pass.
+     * @return void
+     */
+    protected function validate_data() {
+        parent::validate_data();
+
+        if (!isset($this->other['dataid'])) {
+            throw new \coding_exception('The dataid must be set in $other.');
+        }
+    }
+}
diff --git a/mod/data/classes/event/record_updated.php b/mod/data/classes/event/record_updated.php
new file mode 100644 (file)
index 0000000..5ba249a
--- /dev/null
@@ -0,0 +1,90 @@
+<?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/>.
+
+/**
+ * The mod_data record updated event.
+ *
+ * @property-read array $other {
+ *      Extra information about event.
+ *
+ *      @type int dataid the id of the data activity.
+ * }
+ *
+ * @package    mod_data
+ * @copyright  2014 Mark Nelson <markn@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace mod_data\event;
+
+defined('MOODLE_INTERNAL') || die();
+
+class record_updated extends \core\event\base {
+
+    /**
+     * Init method.
+     *
+     * @return void
+     */
+    protected function init() {
+        $this->data['objecttable'] = 'data_records';
+        $this->data['crud'] = 'u';
+        $this->data['edulevel'] = self::LEVEL_TEACHING;
+    }
+
+    /**
+     * Return localised event name.
+     *
+     * @return string
+     */
+    public static function get_name() {
+        return get_string('eventrecordupdated', 'mod_data');
+    }
+
+    /**
+     * Returns description of what happened.
+     *
+     * @return string
+     */
+    public function get_description() {
+        return 'The data record ' . $this->objectid . ' belonging to the database activity ' . $this->other['dataid'] .
+            ' was updated by the user ' . $this->userid;
+    }
+
+    /**
+     * Get the legacy event log data.
+     *
+     * @return array
+     */
+    public function get_legacy_logdata() {
+        return array($this->courseid, 'data', 'update', 'view.php?d=' . $this->other['dataid'] . '&amp;rid=' . $this->objectid,
+            $this->other['dataid'], $this->contextinstanceid);
+    }
+
+    /**
+     * Custom validation.
+     *
+     * @throws \coding_exception when validation does not pass.
+     * @return void
+     */
+    protected function validate_data() {
+        parent::validate_data();
+
+        if (!isset($this->other['dataid'])) {
+            throw new \coding_exception('The dataid must be set in $other.');
+        }
+    }
+}
diff --git a/mod/data/classes/event/template_updated.php b/mod/data/classes/event/template_updated.php
new file mode 100644 (file)
index 0000000..e89424d
--- /dev/null
@@ -0,0 +1,88 @@
+<?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/>.
+
+/**
+ * The mod_data template updated event.
+ *
+ * @property-read array $other {
+ *      Extra information about event.
+ *
+ *      @type int dataid the id of the data activity.
+ * }
+ *
+ * @package    mod_data
+ * @copyright  2014 Mark Nelson <markn@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace mod_data\event;
+
+defined('MOODLE_INTERNAL') || die();
+
+class template_updated extends \core\event\base {
+
+    /**
+     * Init method.
+     *
+     * @return void
+     */
+    protected function init() {
+        $this->data['crud'] = 'u';
+        $this->data['edulevel'] = self::LEVEL_OTHER;
+    }
+
+    /**
+     * Return localised event name.
+     *
+     * @return string
+     */
+    public static function get_name() {
+        return get_string('eventtemplateupdated', 'mod_data');
+    }
+
+    /**
+     * Returns description of what happened.
+     *
+     * @return string
+     */
+    public function get_description() {
+        return 'The template for the database activity ' . $this->other['dataid'] . ' was updated by the user ' . $this->userid;
+    }
+
+    /**
+     * Get the legacy event log data.
+     *
+     * @return array
+     */
+    public function get_legacy_logdata() {
+        return array($this->courseid, 'data', 'templates saved', 'templates.php?id=' . $this->contextinstanceid .
+            '&amp;d=' . $this->other['dataid'], $this->other['dataid'], $this->contextinstanceid);
+    }
+
+    /**
+     * Custom validation.
+     *
+     * @throws \coding_exception when validation does not pass.
+     * @return void
+     */
+    protected function validate_data() {
+        parent::validate_data();
+
+        if (!isset($this->other['dataid'])) {
+            throw new \coding_exception('The dataid must be set in $other.');
+        }
+    }
+}
diff --git a/mod/data/classes/event/template_viewed.php b/mod/data/classes/event/template_viewed.php
new file mode 100644 (file)
index 0000000..bcacde9
--- /dev/null
@@ -0,0 +1,89 @@
+<?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/>.
+
+/**
+ * The mod_data templates viewed event.
+ *
+ * @property-read array $other {
+ *      Extra information about event.
+ *
+ *      @type int dataid the id of the data activity.
+ * }
+ *
+ * @package    mod_data
+ * @copyright  2014 Mark Nelson <markn@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace mod_data\event;
+
+defined('MOODLE_INTERNAL') || die();
+
+class template_viewed extends \core\event\base {
+
+    /**
+     * Init method.
+     *
+     * @return void
+     */
+    protected function init() {
+        $this->data['crud'] = 'r';
+        $this->data['edulevel'] = self::LEVEL_OTHER;
+    }
+
+    /**
+     * Return localised event name.
+     *
+     * @return string
+     */
+    public static function get_name() {
+        return get_string('eventtemplateviewed', 'mod_data');
+    }
+
+    /**
+     * Returns description of what happened.
+     *
+     * @return string
+     */
+    public function get_description() {
+        return 'The template for the database activity with the id ' . $this->other['dataid'] . ' was viewed by the ' .
+            'user with the id ' . $this->userid;
+    }
+
+    /**
+     * Get the legacy event log data.
+     *
+     * @return array
+     */
+    public function get_legacy_logdata() {
+        return array($this->courseid, 'data', 'templates view', 'templates.php?id=' . $this->contextinstanceid .
+            '&amp;d=' . $this->other['dataid'], $this->other['dataid'], $this->contextinstanceid);
+    }
+
+    /**
+     * Custom validation.
+     *
+     * @throws \coding_exception when validation does not pass.
+     * @return void
+     */
+    protected function validate_data() {
+        parent::validate_data();
+
+        if (!isset($this->other['dataid'])) {
+            throw new \coding_exception('The dataid must be set in $other.');
+        }
+    }
+}
index 13727ae..0f04530 100644 (file)
@@ -186,7 +186,17 @@ if ($datarecord = data_submitted() and confirm_sesskey()) {
             }
         }
 
-        add_to_log($course->id, 'data', 'update', "view.php?d=$data->id&amp;rid=$rid", $data->id, $cm->id);
+        // Trigger an event for updating this record.
+        $event = \mod_data\event\record_updated::create(array(
+            'objectid' => $rid,
+            'context' => $context,
+            'courseid' => $course->id,
+            'other' => array(
+                'dataid' => $data->id
+            )
+        ));
+        $event->add_record_snapshot('data', $data);
+        $event->trigger();
 
         redirect($CFG->wwwroot.'/mod/data/view.php?d='.$data->id.'&rid='.$rid);
 
@@ -236,8 +246,6 @@ if ($datarecord = data_submitted() and confirm_sesskey()) {
                 }
             }
 
-            add_to_log($course->id, 'data', 'add', "view.php?d=$data->id&amp;rid=$recordid", $data->id, $cm->id);
-
             if (!empty($datarecord->saveandview)) {
                 redirect($CFG->wwwroot.'/mod/data/view.php?d='.$data->id.'&rid='.$recordid);
             }
index a24d628..210311a 100644 (file)
@@ -121,9 +121,6 @@ switch ($mode) {
             /// Update some templates
                 data_append_new_field_to_templates($data, $fieldinput->name);
 
-                add_to_log($course->id, 'data', 'fields add',
-                           "field.php?d=$data->id&amp;mode=display&amp;fid=$fid", $fid, $cm->id);
-
                 $displaynoticegood = get_string('fieldadded','data');
             }
         }
@@ -163,9 +160,6 @@ switch ($mode) {
             /// Update the templates.
                 data_replace_field_in_templates($data, $oldfieldname, $field->field->name);
 
-                add_to_log($course->id, 'data', 'fields update',
-                           "field.php?d=$data->id&amp;mode=display&amp;fid=$fid", $fid, $cm->id);
-
                 $displaynoticegood = get_string('fieldupdated','data');
             }
         }
@@ -194,9 +188,6 @@ switch ($mode) {
                         $DB->update_record('data', $rec);
                     }
 
-                    add_to_log($course->id, 'data', 'fields delete',
-                               "field.php?d=$data->id", $field->field->name, $cm->id);
-
                     $displaynoticegood = get_string('fielddeleted', 'data');
                 }
 
index de7c74b..8620a34 100644 (file)
@@ -39,7 +39,11 @@ $PAGE->set_pagelayout('incourse');
 
 $context = context_course::instance($course->id);
 
-add_to_log($course->id, "data", "view all", "index.php?id=$course->id", "");
+$params = array(
+    'context' => context_course::instance($course->id)
+);
+$event = \mod_data\event\course_module_instance_list_viewed::create($params);
+$event->trigger();
 
 $strname = get_string('name');
 $strdata = get_string('modulename','data');
index d0d9110..919d5c0 100644 (file)
@@ -119,6 +119,14 @@ $string['editordisable'] = 'Disable editor';
 $string['editorenable'] = 'Enable editor';
 $string['emptyadd'] = 'The Add template is empty, generating a default form...';
 $string['emptyaddform'] = 'You did not fill out any fields!';
+$string['eventfieldcreated'] = 'Field created';
+$string['eventfielddeleted'] = 'Field deleted';
+$string['eventfieldupdated'] = 'Field updated';
+$string['eventrecordcreated'] = 'Record created';
+$string['eventrecorddeleted'] = 'Record deleted';
+$string['eventrecordupdated'] = 'Record updated';
+$string['eventtemplateupdated'] = 'Template updated';
+$string['eventtemplateviewed'] = 'Templates viewed';
 $string['fileencoding'] = 'Encoding';
 $string['entries'] = 'Entries';
 $string['entrieslefttoadd'] = 'You must add {$a->entriesleft} more entry/entries in order to complete this activity';
index 8123cc8..4bf3478 100644 (file)
@@ -188,6 +188,18 @@ class data_field_base {     // Base class for Database Field Types (see field/*/
         }
 
         $this->field->id = $DB->insert_record('data_fields',$this->field);
+
+        // Trigger an event for creating this field.
+        $event = \mod_data\event\field_created::create(array(
+            'objectid' => $this->field->id,
+            'context' => $this->context,
+            'other' => array(
+                'fieldname' => $this->field->name,
+                'dataid' => $this->data->id
+            )
+        ));
+        $event->trigger();
+
         return true;
     }
 
@@ -202,6 +214,18 @@ class data_field_base {     // Base class for Database Field Types (see field/*/
         global $DB;
 
         $DB->update_record('data_fields', $this->field);
+
+        // Trigger an event for updating this field.
+        $event = \mod_data\event\field_updated::create(array(
+            'objectid' => $this->field->id,
+            'context' => $this->context,
+            'other' => array(
+                'fieldname' => $this->field->name,
+                'dataid' => $this->data->id
+            )
+        ));
+        $event->trigger();
+
         return true;
     }
 
@@ -215,9 +239,25 @@ class data_field_base {     // Base class for Database Field Types (see field/*/
         global $DB;
 
         if (!empty($this->field->id)) {
+            // Get the field before we delete it.
+            $field = $DB->get_record('data_fields', array('id' => $this->field->id));
+
             $this->delete_content();
             $DB->delete_records('data_fields', array('id'=>$this->field->id));
+
+            // Trigger an event for deleting this field.
+            $event = \mod_data\event\field_deleted::create(array(
+                'objectid' => $this->field->id,
+                'context' => $this->context,
+                'other' => array(
+                    'fieldname' => $this->field->name,
+                    'dataid' => $this->data->id
+                 )
+            ));
+            $event->add_record_snapshot('data_fields', $field);
+            $event->trigger();
         }
+
         return true;
     }
 
@@ -802,7 +842,19 @@ function data_add_record($data, $groupid=0){
     } else {
         $record->approved = 0;
     }
-    return $DB->insert_record('data_records', $record);
+    $record->id = $DB->insert_record('data_records', $record);
+
+    // Trigger an event for creating this record.
+    $event = \mod_data\event\record_created::create(array(
+        'objectid' => $record->id,
+        'context' => $context,
+        'other' => array(
+            'dataid' => $data->id
+        )
+    ));
+    $event->trigger();
+
+    return $record->id;
 }
 
 /**
@@ -3684,7 +3736,18 @@ function data_delete_record($recordid, $data, $courseid, $cmid) {
                 $DB->delete_records('data_content', array('recordid'=>$deleterecord->id));
                 $DB->delete_records('data_records', array('id'=>$deleterecord->id));
 
-                add_to_log($courseid, 'data', 'record delete', "view.php?id=$cmid", $data->id, $cmid);
+                // Trigger an event for deleting this record.
+                $event = \mod_data\event\record_deleted::create(array(
+                    'objectid' => $deleterecord->id,
+                    'context' => context_module::instance($cmid),
+                    'courseid' => $courseid,
+                    'other' => array(
+                        'dataid' => $deleterecord->dataid
+                    )
+                ));
+                $event->add_record_snapshot('data_records', $deleterecord);
+                $event->trigger();
+
                 return true;
             }
         }
index 8adce53..237ec21 100644 (file)
@@ -73,8 +73,16 @@ if (!$DB->count_records('data_fields', array('dataid'=>$data->id))) {      // Br
     redirect($CFG->wwwroot.'/mod/data/field.php?d='.$data->id);  // Redirect to field entry
 }
 
-add_to_log($course->id, 'data', 'templates view', "templates.php?id=$cm->id&amp;d=$data->id", $data->id, $cm->id);
-
+// Trigger an event for viewing templates.
+$event = \mod_data\event\template_viewed::create(array(
+    'context' => $context,
+    'courseid' => $course->id,
+    'other' => array(
+        'dataid' => $data->id
+    )
+));
+$event->add_record_snapshot('data', $data);
+$event->trigger();
 
 /// Print the page header
 
@@ -136,7 +144,16 @@ if (($mytemplate = data_submitted()) && confirm_sesskey()) {
             if (empty($disableeditor) && empty($enableeditor)) {
                 $DB->update_record('data', $newtemplate);
                 echo $OUTPUT->notification(get_string('templatesaved', 'data'), 'notifysuccess');
-                add_to_log($course->id, 'data', 'templates saved', "templates.php?id=$cm->id&amp;d=$data->id", $data->id, $cm->id);
+
+                // Trigger an event for saving the templates.
+                $event = \mod_data\event\template_updated::create(array(
+                    'context' => $context,
+                    'courseid' => $course->id,
+                    'other' => array(
+                        'dataid' => $data->id,
+                    )
+                ));
+                $event->trigger();
             }
         }
     }
diff --git a/mod/data/tests/events_test.php b/mod/data/tests/events_test.php
new file mode 100644 (file)
index 0000000..2a5c375
--- /dev/null
@@ -0,0 +1,341 @@
+<?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/>.
+
+/**
+ * Events tests.
+ *
+ * @package mod_data
+ * @category test
+ * @copyright 2014 Mark Nelson <markn@moodle.com>
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+global $CFG;
+
+class mod_data_events_testcase extends advanced_testcase {
+
+    /**
+     * Test set up.
+     *
+     * This is executed before running any test in this file.
+     */
+    public function setUp() {
+        $this->resetAfterTest();
+    }
+
+    /**
+     * Test the field created event.
+     */
+    public function test_field_created() {
+        $this->setAdminUser();
+
+        // Create a course we are going to add a data module to.
+        $course = $this->getDataGenerator()->create_course();
+
+        // The generator used to create a data module.
+        $generator = $this->getDataGenerator()->get_plugin_generator('mod_data');
+
+        // Create a data module.
+        $data = $generator->create_instance(array('course' => $course->id));
+
+        // Now we want to create a field.
+        $field = data_get_field_new('text', $data);
+        $fielddata = new stdClass();
+        $fielddata->name = 'Test';
+        $fielddata->description = 'Test description';
+        $field->define_field($fielddata);
+
+        // Trigger and capture the event for creating a field.
+        $sink = $this->redirectEvents();
+        $field->insert_field();
+        $events = $sink->get_events();
+        $event = reset($events);
+
+        // Check that the event data is valid.
+        $this->assertInstanceOf('\mod_data\event\field_created', $event);
+        $this->assertEquals(context_module::instance($data->cmid), $event->get_context());
+        $expected = array($course->id, 'data', 'fields add', 'field.php?d=' . $data->id . '&amp;mode=display&amp;fid=' .
+            $field->field->id, $field->field->id, $data->cmid);
+        $this->assertEventLegacyLogData($expected, $event);
+    }
+
+    /**
+     * Test the field updated event.
+     */
+    public function test_field_updated() {
+        $this->setAdminUser();
+
+        // Create a course we are going to add a data module to.
+        $course = $this->getDataGenerator()->create_course();
+
+        // The generator used to create a data module.
+        $generator = $this->getDataGenerator()->get_plugin_generator('mod_data');
+
+        // Create a data module.
+        $data = $generator->create_instance(array('course' => $course->id));
+
+        // Now we want to create a field.
+        $field = data_get_field_new('text', $data);
+        $fielddata = new stdClass();
+        $fielddata->name = 'Test';
+        $fielddata->description = 'Test description';
+        $field->define_field($fielddata);
+        $field->insert_field();
+
+        // Trigger and capture the event for updating the field.
+        $sink = $this->redirectEvents();
+        $field->update_field();
+        $events = $sink->get_events();
+        $event = reset($events);
+
+        // Check that the event data is valid.
+        $this->assertInstanceOf('\mod_data\event\field_updated', $event);
+        $this->assertEquals(context_module::instance($data->cmid), $event->get_context());
+        $expected = array($course->id, 'data', 'fields update', 'field.php?d=' . $data->id . '&amp;mode=display&amp;fid=' .
+            $field->field->id, $field->field->id, $data->cmid);
+        $this->assertEventLegacyLogData($expected, $event);
+    }
+
+    /**
+     * Test the field deleted event.
+     */
+    public function test_field_deleted() {
+        $this->setAdminUser();
+
+        // Create a course we are going to add a data module to.
+        $course = $this->getDataGenerator()->create_course();
+
+        // The generator used to create a data module.
+        $generator = $this->getDataGenerator()->get_plugin_generator('mod_data');
+
+        // Create a data module.
+        $data = $generator->create_instance(array('course' => $course->id));
+
+        // Now we want to create a field.
+        $field = data_get_field_new('text', $data);
+        $fielddata = new stdClass();
+        $fielddata->name = 'Test';
+        $fielddata->description = 'Test description';
+        $field->define_field($fielddata);
+        $field->insert_field();
+
+        // Trigger and capture the event for deleting the field.
+        $sink = $this->redirectEvents();
+        $field->delete_field();
+        $events = $sink->get_events();
+        $event = reset($events);
+
+        // Check that the event data is valid.
+        $this->assertInstanceOf('\mod_data\event\field_deleted', $event);
+        $this->assertEquals(context_module::instance($data->cmid), $event->get_context());
+        $expected = array($course->id, 'data', 'fields delete', 'field.php?d=' . $data->id, $field->field->name, $data->cmid);
+        $this->assertEventLegacyLogData($expected, $event);
+    }
+
+    /**
+     * Test the record created event.
+     */
+    public function test_record_created() {
+        // Create a course we are going to add a data module to.
+        $course = $this->getDataGenerator()->create_course();
+
+        // The generator used to create a data module.
+        $generator = $this->getDataGenerator()->get_plugin_generator('mod_data');
+
+        // Create a data module.
+        $data = $generator->create_instance(array('course' => $course->id));
+
+        // Trigger and capture the event for creating the record.
+        $sink = $this->redirectEvents();
+        $recordid = data_add_record($data);
+        $events = $sink->get_events();
+        $event = reset($events);
+
+        // Check that the event data is valid.
+        $this->assertInstanceOf('\mod_data\event\record_created', $event);
+        $this->assertEquals(context_module::instance($data->cmid), $event->get_context());
+        $expected = array($course->id, 'data', 'add', 'view.php?d=' . $data->id . '&amp;rid=' . $recordid,
+            $data->id, $data->cmid);
+        $this->assertEventLegacyLogData($expected, $event);
+    }
+
+    /**
+     * Test the record updated event.
+     *
+     * There is no external API for updating a record, so the unit test will simply create
+     * and trigger the event and ensure the legacy log data is returned as expected.
+     */
+    public function test_record_updated() {
+        // Create a course we are going to add a data module to.
+        $course = $this->getDataGenerator()->create_course();
+
+        // The generator used to create a data module.
+        $generator = $this->getDataGenerator()->get_plugin_generator('mod_data');
+
+        // Create a data module.
+        $data = $generator->create_instance(array('course' => $course->id));
+
+        // Trigger an event for updating this record.
+        $event = \mod_data\event\record_updated::create(array(
+            'objectid' => 1,
+            'context' => context_module::instance($data->cmid),
+            'courseid' => $course->id,
+            'other' => array(
+                'dataid' => $data->id
+            )
+        ));
+
+        // Trigger and capture the event for updating the data record.
+        $sink = $this->redirectEvents();
+        $event->trigger();
+        $events = $sink->get_events();
+        $event = reset($events);
+
+        // Check that the event data is valid.
+        $this->assertInstanceOf('\mod_data\event\record_updated', $event);
+        $this->assertEquals(context_module::instance($data->cmid), $event->get_context());
+        $expected = array($course->id, 'data', 'update', 'view.php?d=' . $data->id . '&amp;rid=1', $data->id, $data->cmid);
+        $this->assertEventLegacyLogData($expected, $event);
+    }
+
+    /**
+     * Test the record deleted event.
+     */
+    public function test_record_deleted() {
+        global $DB;
+
+        // Create a course we are going to add a data module to.
+        $course = $this->getDataGenerator()->create_course();
+
+        // The generator used to create a data module.
+        $generator = $this->getDataGenerator()->get_plugin_generator('mod_data');
+
+        // Create a data module.
+        $data = $generator->create_instance(array('course' => $course->id));
+
+        // Now we want to create a field.
+        $field = data_get_field_new('text', $data);
+        $fielddata = new stdClass();
+        $fielddata->name = 'Test';
+        $fielddata->description = 'Test description';
+        $field->define_field($fielddata);
+        $field->insert_field();
+
+        // Create data record.
+        $datarecords = new stdClass();
+        $datarecords->userid = '2';
+        $datarecords->dataid = $data->id;
+        $datarecords->id = $DB->insert_record('data_records', $datarecords);
+
+        // Create data content.
+        $datacontent = new stdClass();
+        $datacontent->fieldid = $field->field->id;
+        $datacontent->recordid = $datarecords->id;
+        $datacontent->id = $DB->insert_record('data_content', $datacontent);
+
+        // Trigger and capture the event for deleting the data record.
+        $sink = $this->redirectEvents();
+        data_delete_record($datarecords->id, $data, $course->id, $data->cmid);
+        $events = $sink->get_events();
+        $event = reset($events);
+
+        // Check that the event data is valid.
+        $this->assertInstanceOf('\mod_data\event\record_deleted', $event);
+        $this->assertEquals(context_module::instance($data->cmid), $event->get_context());
+        $expected = array($course->id, 'data', 'record delete', 'view.php?id=' . $data->cmid, $data->id, $data->cmid);
+        $this->assertEventLegacyLogData($expected, $event);
+    }
+
+    /**
+     * Test the template viewed event.
+     *
+     * There is no external API for viewing templates, so the unit test will simply create
+     * and trigger the event and ensure the legacy log data is returned as expected.
+     */
+    public function test_template_viewed() {
+        // Create a course we are going to add a data module to.
+        $course = $this->getDataGenerator()->create_course();
+
+        // The generator used to create a data module.
+        $generator = $this->getDataGenerator()->get_plugin_generator('mod_data');
+
+        // Create a data module.
+        $data = $generator->create_instance(array('course' => $course->id));
+
+        // Trigger an event for updating this record.
+        $event = \mod_data\event\template_viewed::create(array(
+            'context' => context_module::instance($data->cmid),
+            'courseid' => $course->id,
+            'other' => array(
+                'dataid' => $data->id
+            )
+        ));
+
+        // Trigger and capture the event for updating the data record.
+        $sink = $this->redirectEvents();
+        $event->trigger();
+        $events = $sink->get_events();
+        $event = reset($events);
+
+        // Check that the event data is valid.
+        $this->assertInstanceOf('\mod_data\event\template_viewed', $event);
+        $this->assertEquals(context_module::instance($data->cmid), $event->get_context());
+        $expected = array($course->id, 'data', 'templates view', 'templates.php?id=' . $data->cmid . '&amp;d=' .
+            $data->id, $data->id, $data->cmid);
+        $this->assertEventLegacyLogData($expected, $event);
+    }
+
+    /**
+     * Test the template updated event.
+     *
+     * There is no external API for updating a template, so the unit test will simply create
+     * and trigger the event and ensure the legacy log data is returned as expected.
+     */
+    public function test_template_updated() {
+        // Create a course we are going to add a data module to.
+        $course = $this->getDataGenerator()->create_course();
+
+        // The generator used to create a data module.
+        $generator = $this->getDataGenerator()->get_plugin_generator('mod_data');
+
+        // Create a data module.
+        $data = $generator->create_instance(array('course' => $course->id));
+
+        // Trigger an event for updating this record.
+        $event = \mod_data\event\template_updated::create(array(
+            'context' => context_module::instance($data->cmid),
+            'courseid' => $course->id,
+            'other' => array(
+                'dataid' => $data->id,
+            )
+        ));
+
+        // Trigger and capture the event for updating the data record.
+        $sink = $this->redirectEvents();
+        $event->trigger();
+        $events = $sink->get_events();
+        $event = reset($events);
+
+        // Check that the event data is valid.
+        $this->assertInstanceOf('\mod_data\event\template_updated', $event);
+        $this->assertEquals(context_module::instance($data->cmid), $event->get_context());
+        $expected = array($course->id, 'data', 'templates saved', 'templates.php?id=' . $data->cmid . '&amp;d=' .
+            $data->id, $data->id, $data->cmid);
+        $this->assertEventLegacyLogData($expected, $event);
+    }
+}
index 557ce4e..1454f90 100644 (file)
         set_user_preference('data_perpage_'.$data->id, $perpage);
     }
 
-    add_to_log($course->id, 'data', 'view', "view.php?id=$cm->id", $data->id, $cm->id);
-
+    $params = array(
+        'context' => $context,
+        'objectid' => $data->id
+    );
+    $event = \mod_data\event\course_module_viewed::create($params);
+    $event->add_record_snapshot('data', $data);
+    $event->trigger();
 
     $urlparams = array('d' => $data->id);
     if ($record) {
index 9b88976..ac66183 100644 (file)
@@ -364,7 +364,7 @@ function glossary_get_recent_mod_activity(&$activities, &$index, $timestart, $co
     $params['timestart'] = $timestart;
     $params['glossaryid'] = $cm->instance;
 
-    $ufields = user_picture::fields('u', array('lastaccess', 'firstname', 'lastname', 'email', 'picture', 'imagealt'));
+    $ufields = user_picture::fields('u');
     $entries = $DB->get_records_sql("
               SELECT ge.id AS entryid, ge.*, $ufields
                 FROM {glossary_entries} ge
@@ -399,6 +399,9 @@ function glossary_get_recent_mod_activity(&$activities, &$index, $timestart, $co
         }
 
         $tmpactivity                       = new stdClass();
+        $tmpactivity->user = username_load_fields_from_object(new stdClass(), $entry, null,
+                explode(',', user_picture::fields()));
+        $tmpactivity->user->fullname       = fullname($tmpactivity->user, $viewfullnames);
         $tmpactivity->type                 = 'glossary';
         $tmpactivity->cmid                 = $cm->id;
         $tmpactivity->glossaryid           = $entry->glossaryid;
@@ -409,14 +412,6 @@ function glossary_get_recent_mod_activity(&$activities, &$index, $timestart, $co
         $tmpactivity->content->entryid     = $entry->entryid;
         $tmpactivity->content->concept     = $entry->concept;
         $tmpactivity->content->definition  = $entry->definition;
-        $tmpactivity->user                 = new stdClass();
-        $tmpactivity->user->id             = $entry->userid;
-        $tmpactivity->user->firstname      = $entry->firstname;
-        $tmpactivity->user->lastname       = $entry->lastname;
-        $tmpactivity->user->fullname       = fullname($entry, $viewfullnames);
-        $tmpactivity->user->picture        = $entry->picture;
-        $tmpactivity->user->imagealt       = $entry->imagealt;
-        $tmpactivity->user->email          = $entry->email;
 
         $activities[$index++] = $tmpactivity;
     }
index 6dac738..b29ae77 100644 (file)
@@ -847,9 +847,10 @@ function quiz_get_recent_mod_activity(&$activities, &$index, $timestart,
     $params['timestart'] = $timestart;
     $params['quizid'] = $quiz->id;
 
+    $ufields = user_picture::fields('u');
     if (!$attempts = $DB->get_records_sql("
               SELECT qa.*,
-                     u.firstname, u.lastname, u.email, u.picture, u.imagealt
+                     {$ufields}
                 FROM {quiz_attempts} qa
                      JOIN {user} u ON u.id = qa.userid
                      $groupjoin
@@ -919,14 +920,9 @@ function quiz_get_recent_mod_activity(&$activities, &$index, $timestart,
             $tmpactivity->content->maxgrade  = null;
         }
 
-        $tmpactivity->user = new stdClass();
-        $tmpactivity->user->id        = $attempt->userid;
-        $tmpactivity->user->firstname = $attempt->firstname;
-        $tmpactivity->user->lastname  = $attempt->lastname;
-        $tmpactivity->user->fullname  = fullname($attempt, $viewfullnames);
-        $tmpactivity->user->picture   = $attempt->picture;
-        $tmpactivity->user->imagealt  = $attempt->imagealt;
-        $tmpactivity->user->email     = $attempt->email;
+        $tmpactivity->user = username_load_fields_from_object(new stdClass(), $attempt, null,
+                explode(',', user_picture::fields()));
+        $tmpactivity->user->fullname  = fullname($tmpactivity->user, $viewfullnames);
 
         $activities[$index++] = $tmpactivity;
     }
index 14f2483..93280a7 100644 (file)
@@ -387,7 +387,7 @@ class qformat_default {
             $question->modifiedby = $USER->id;
             $question->timemodified = time();
             $fileoptions = array(
-                    'subdirs' => false,
+                    'subdirs' => true,
                     'maxfiles' => -1,
                     'maxbytes' => 0,
                 );
index df9e609..304d379 100644 (file)
@@ -169,11 +169,13 @@ class qformat_xml extends qformat_default {
         }
         $fs = get_file_storage();
         $itemid = file_get_unused_draft_itemid();
-        $filenames = array();
+        $filepaths = array();
         foreach ($xml as $file) {
-            $filename = $file['@']['name'];
-            if (in_array($filename, $filenames)) {
-                debugging('Duplicate file in XML: ' . $filename, DEBUG_DEVELOPER);
+            $filename = $this->getpath($file, array('@', 'name'), '', true);
+            $filepath = $this->getpath($file, array('@', 'path'), '/', true);
+            $fullpath = $filepath . $filename;
+            if (in_array($fullpath, $filepaths)) {
+                debugging('Duplicate file in XML: ' . $fullpath, DEBUG_DEVELOPER);
                 continue;
             }
             $filerecord = array(
@@ -181,11 +183,11 @@ class qformat_xml extends qformat_default {
                 'component' => 'user',
                 'filearea'  => 'draft',
                 'itemid'    => $itemid,
-                'filepath'  => '/',
+                'filepath'  => $filepath,
                 'filename'  => $filename,
             );
             $fs->create_file_from_string($filerecord, base64_decode($file['#']));
-            $filenames[] = $filename;
+            $filepaths[] = $fullpath;
         }
         return $itemid;
     }
@@ -1092,9 +1094,9 @@ class qformat_xml extends qformat_default {
             if ($file->is_directory()) {
                 continue;
             }
-            $string .= '<file name="' . $file->get_filename() . '" encoding="base64">';
+            $string .= '<file name="' . $file->get_filename() . '" path="' . $file->get_filepath() . '" encoding="base64">';
             $string .= base64_encode($file->get_content());
-            $string .= '</file>';
+            $string .= "</file>\n";
         }
         return $string;
     }
index 8396838..429f787 100644 (file)
@@ -1473,4 +1473,51 @@ END;
         $this->assertEquals('/',          $file->filepath);
         $this->assertEquals(6,            $file->size);
     }
+
+    public function test_import_truefalse_wih_files() {
+        $this->resetAfterTest();
+        $this->setAdminUser();
+
+        $xml = '<question type="truefalse">
+    <name>
+      <text>truefalse</text>
+    </name>
+    <questiontext format="html">
+      <text><![CDATA[<p><a href="@@PLUGINFILE@@/myfolder/moodle.txt">This text file</a> contains the word Moodle.</p>]]></text>
+<file name="moodle.txt" path="/myfolder/" encoding="base64">TW9vZGxl</file>
+    </questiontext>
+    <generalfeedback format="html">
+      <text><![CDATA[<p>For further information, see the documentation about Moodle.</p>]]></text>
+</generalfeedback>
+    <defaultgrade>1.0000000</defaultgrade>
+    <penalty>1.0000000</penalty>
+    <hidden>0</hidden>
+    <answer fraction="100" format="moodle_auto_format">
+      <text>true</text>
+      <feedback format="html">
+        <text></text>
+      </feedback>
+    </answer>
+    <answer fraction="0" format="moodle_auto_format">
+      <text>false</text>
+      <feedback format="html">
+        <text></text>
+      </feedback>
+    </answer>
+  </question>';
+        $xmldata = xmlize($xml);
+
+        $importer = new qformat_xml();
+        $q = $importer->import_truefalse($xmldata['question']);
+
+        $draftitemid = $q->questiontextitemid;
+        $files = file_get_drafarea_files($draftitemid, '/myfolder/');
+
+        $this->assertEquals(1, count($files->list));
+
+        $file = $files->list[0];
+        $this->assertEquals('moodle.txt', $file->filename);
+        $this->assertEquals('/myfolder/', $file->filepath);
+        $this->assertEquals(6,            $file->size);
+    }
 }
index 5bc2f82..09c2c7a 100644 (file)
@@ -58,9 +58,7 @@ class qtype_match_renderer extends qtype_with_combined_feedback_renderer {
             $result .= html_writer::start_tag('tr', array('class' => 'r' . $parity));
             $fieldname = 'sub' . $key;
 
-            $result .= html_writer::tag('td', $question->format_text(
-                    $question->stems[$stemid], $question->stemformat[$stemid],
-                    $qa, 'qtype_match', 'subquestion', $stemid),
+            $result .= html_writer::tag('td', $this->format_stem_text($qa, $stemid),
                     array('class' => 'text'));
 
             $classes = 'control';
@@ -109,6 +107,20 @@ class qtype_match_renderer extends qtype_with_combined_feedback_renderer {
         return $this->combined_feedback($qa);
     }
 
+    /**
+     * Format each question stem. Overwritten by randomsamatch renderer.
+     *
+     * @param question_attempt $qa
+     * @param integer $stemid stem index
+     * @return string
+     */
+    public function format_stem_text($qa, $stemid) {
+        $question = $qa->get_question();
+        return $question->format_text(
+                    $question->stems[$stemid], $question->stemformat[$stemid],
+                    $qa, 'qtype_match', 'subquestion', $stemid);
+    }
+
     protected function format_choices($question) {
         $choices = array();
         foreach ($question->get_choice_order() as $key => $choiceid) {
@@ -125,9 +137,7 @@ class qtype_match_renderer extends qtype_with_combined_feedback_renderer {
         $choices = $this->format_choices($question);
         $right = array();
         foreach ($stemorder as $key => $stemid) {
-            $right[] = $question->format_text($question->stems[$stemid],
-                    $question->stemformat[$stemid], $qa,
-                    'qtype_match', 'subquestion', $stemid) . ' – ' .
+            $right[] = $this->format_stem_text($qa, $stemid) . ' – ' .
                     $choices[$question->get_right_choice_for($stemid)];
         }
 
diff --git a/question/type/randomsamatch/backup/moodle1/lib.php b/question/type/randomsamatch/backup/moodle1/lib.php
new file mode 100644 (file)
index 0000000..b6fba4d
--- /dev/null
@@ -0,0 +1,71 @@
+<?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/>.
+
+/**
+ * Serve question type files
+ *
+ * @package    qtype_randomsamatch
+ * @copyright  2013 Jean-Michel Vedrine
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Random shortanswer matching question type conversion handler.
+ *
+ * @copyright  2013 Jean-Michel Vedrine
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU Public License
+ */
+class moodle1_qtype_randomsamatch_handler extends moodle1_qtype_handler {
+
+    /**
+     * Returns the list of paths within one <QUESTION> that this qtype needs to have included
+     * in the grouped question structure
+     *
+     * @return array of strings
+     */
+    public function get_question_subpaths() {
+        return array(
+            'RANDOMSAMATCH',
+        );
+    }
+
+    /**
+     * Appends the randomsamatch specific information to the question.
+     *
+     * @param array $data grouped question data
+     * @param array $raw grouped raw QUESTION data
+     */
+    public function process_question(array $data, array $raw) {
+
+        // Convert match options.
+        if (isset($data['randomsamatch'])) {
+            $randomsamatch = $data['randomsamatch'][0];
+        } else {
+            $randomsamatch = array('choose' => 4);
+        }
+        $randomsamatch['id'] = $this->converter->get_nextid();
+        $randomsamatch['subcats'] = 1;
+        $randomsamatch['correctfeedback'] = '';
+        $randomsamatch['correctfeedbackformat'] = FORMAT_HTML;
+        $randomsamatch['partiallycorrectfeedback'] = '';
+        $randomsamatch['partiallycorrectfeedbackformat'] = FORMAT_HTML;
+        $randomsamatch['incorrectfeedback'] = '';
+        $randomsamatch['incorrectfeedbackformat'] = FORMAT_HTML;
+        $this->write_xml('randomsamatch', $randomsamatch, array('/randomsamatch/id'));
+    }
+}
index aaa0acf..2ac9191 100644 (file)
@@ -49,14 +49,16 @@ class backup_qtype_randomsamatch_plugin extends backup_qtype_plugin {
 
         // Now create the qtype own structures.
         $randomsamatch = new backup_nested_element('randomsamatch', array('id'), array(
-            'choose'));
+            'choose', 'subcats', 'correctfeedback', 'correctfeedbackformat',
+            'partiallycorrectfeedback', 'partiallycorrectfeedbackformat',
+            'incorrectfeedback', 'incorrectfeedbackformat', 'shownumcorrect'));
 
         // Now the own qtype tree.
         $pluginwrapper->add_child($randomsamatch);
 
         // Set source to populate the data.
-        $randomsamatch->set_source_table('question_randomsamatch',
-                array('question' => backup::VAR_PARENTID));
+        $randomsamatch->set_source_table('qtype_randomsamatch_options',
+                array('questionid' => backup::VAR_PARENTID));
 
         return $plugin;
     }
index 4e3961c..a86e0e3 100644 (file)
@@ -41,7 +41,7 @@ class restore_qtype_randomsamatch_plugin extends restore_qtype_plugin {
 
         $paths = array();
 
-        // Add own qtype stuff
+        // Add own qtype stuff.
         $elename = 'randomsamatch';
         $elepath = $this->get_pathfor('/randomsamatch');
         $paths[] = new restore_path_element($elename, $elepath);
@@ -64,14 +64,34 @@ class restore_qtype_randomsamatch_plugin extends restore_qtype_plugin {
         $questioncreated = $this->get_mappingid('question_created', $oldquestionid) ? true : false;
 
         // If the question has been created by restore, we need to create its
-        // question_randomsamatch too.
+        // qtype_randomsamatch_options too.
         if ($questioncreated) {
+            // Fill in some field that were added in 2.1, and so which may be missing
+            // from backups made in older versions of Moodle.
+            if (!isset($data->subcats)) {
+                $data->subcats = 1;
+            }
+            if (!isset($data->correctfeedback)) {
+                $data->correctfeedback = '';
+                $data->correctfeedbackformat = FORMAT_HTML;
+            }
+            if (!isset($data->partiallycorrectfeedback)) {
+                $data->partiallycorrectfeedback = '';
+                $data->partiallycorrectfeedbackformat = FORMAT_HTML;
+            }
+            if (!isset($data->incorrectfeedback)) {
+                $data->incorrectfeedback = '';
+                $data->incorrectfeedbackformat = FORMAT_HTML;
+            }
+            if (!isset($data->shownumcorrect)) {
+                $data->shownumcorrect = 0;
+            }
             // Adjust some columns.
-            $data->question = $newquestionid;
+            $data->questionid = $newquestionid;
             // Insert record.
-            $newitemid = $DB->insert_record('question_randomsamatch', $data);
+            $newitemid = $DB->insert_record('qtype_randomsamatch_options', $data);
             // Create mapping.
-            $this->set_mapping('question_randomsamatch', $oldid, $newitemid);
+            $this->set_mapping('qtype_randomsamatch_options', $oldid, $newitemid);
         }
     }
 
@@ -82,7 +102,7 @@ class restore_qtype_randomsamatch_plugin extends restore_qtype_plugin {
      * answer is one comma separated list of hypen separated pairs
      * containing question->id and question_answers->id
      */
-    public function recode_state_answer($state) {
+    public function recode_legacy_state_answer($state) {
         $answer = $state->answer;
         $resultarr = array();
         foreach (explode(',', $answer) as $pair) {
@@ -95,4 +115,17 @@ class restore_qtype_randomsamatch_plugin extends restore_qtype_plugin {
         }
         return implode(',', $resultarr);
     }
+
+    /**
+     * Return the contents of this qtype to be processed by the links decoder.
+     */
+    public static function define_decode_contents() {
+
+        $contents = array();
+
+        $fields = array('correctfeedback', 'partiallycorrectfeedback', 'incorrectfeedback');
+        $contents[] = new restore_decode_content('qtype_randomsamatch_options', $fields, 'qtype_randomsamatch_options');
+
+        return $contents;
+    }
 }
index 682de29..b3f917c 100644 (file)
@@ -4,15 +4,23 @@
     xsi:noNamespaceSchemaLocation="../../../../lib/xmldb/xmldb.xsd"
 >
   <TABLES>
-    <TABLE NAME="question_randomsamatch" COMMENT="Info about a random short-answer matching question">
+    <TABLE NAME="qtype_randomsamatch_options" COMMENT="Info about a random short-answer matching question">
       <FIELDS>
         <FIELD NAME="id" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="true"/>
-        <FIELD NAME="question" TYPE="int" LENGTH="10" NOTNULL="true" DEFAULT="0" SEQUENCE="false" COMMENT="Foreign key references question.id."/>
+        <FIELD NAME="questionid" TYPE="int" LENGTH="10" NOTNULL="true" DEFAULT="0" SEQUENCE="false" COMMENT="Foreign key references question.id."/>
         <FIELD NAME="choose" TYPE="int" LENGTH="10" NOTNULL="true" DEFAULT="4" SEQUENCE="false" COMMENT="Number of subquestions to randomly generate."/>
+        <FIELD NAME="subcats" TYPE="int" LENGTH="2" NOTNULL="true" DEFAULT="1" SEQUENCE="false" COMMENT="Whether to include or not the subcategories."/>
+        <FIELD NAME="correctfeedback" TYPE="text" NOTNULL="true" SEQUENCE="false" COMMENT="Feedback shown for any correct response."/>
+        <FIELD NAME="correctfeedbackformat" TYPE="int" LENGTH="2" NOTNULL="true" DEFAULT="0" SEQUENCE="false"/>
+        <FIELD NAME="partiallycorrectfeedback" TYPE="text" NOTNULL="true" SEQUENCE="false" COMMENT="Feedback shown for any partially correct response."/>
+        <FIELD NAME="partiallycorrectfeedbackformat" TYPE="int" LENGTH="2" NOTNULL="true" DEFAULT="0" SEQUENCE="false"/>
+        <FIELD NAME="incorrectfeedback" TYPE="text" NOTNULL="true" SEQUENCE="false" COMMENT="Feedback shown for any incorrect response."/>
+        <FIELD NAME="incorrectfeedbackformat" TYPE="int" LENGTH="2" NOTNULL="true" DEFAULT="0" SEQUENCE="false"/>
+        <FIELD NAME="shownumcorrect" TYPE="int" LENGTH="2" NOTNULL="true" DEFAULT="0" SEQUENCE="false" COMMENT="If true, then when the user gets the question partially correct, tell them how many choices they got correct alongside the feedback."/>
       </FIELDS>
       <KEYS>
         <KEY NAME="primary" TYPE="primary" FIELDS="id"/>
-        <KEY NAME="question" TYPE="foreign" FIELDS="question" REFTABLE="question" REFFIELDS="id"/>
+        <KEY NAME="questionid" TYPE="foreign-unique" FIELDS="questionid" REFTABLE="question" REFFIELDS="id"/>
       </KEYS>
     </TABLE>
   </TABLES>
diff --git a/question/type/randomsamatch/db/upgrade.php b/question/type/randomsamatch/db/upgrade.php
new file mode 100644 (file)
index 0000000..fc0e1b3
--- /dev/null
@@ -0,0 +1,190 @@
+<?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/>.
+
+/**
+ * Matching question type upgrade code.
+ *
+ * @package   qtype_randomsamatch
+ * @copyright 2013 Jean-Michel Vedrine
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+
+defined('MOODLE_INTERNAL') || die();
+
+
+/**
+ * Upgrade code for the random short answer matching question type.
+ * @param int $oldversion the version we are upgrading from.
+ */
+function xmldb_qtype_randomsamatch_upgrade($oldversion) {
+    global $CFG, $DB;
+
+    $dbman = $DB->get_manager();
+
+    // Moodle v2.2.0 release upgrade line.
+    // Put any upgrade step following this.
+
+    // Moodle v2.3.0 release upgrade line.
+    // Put any upgrade step following this.
+
+    // Moodle v2.4.0 release upgrade line.
+    // Put any upgrade step following this.
+
+    if ($oldversion < 2013110501) {
+
+        // Define table question_randomsamatch to be renamed to qtype_randomsamatch_options.
+        $table = new xmldb_table('question_randomsamatch');
+
+        // Launch rename table for qtype_randomsamatch_options.
+        if ($dbman->table_exists($table)) {
+            $dbman->rename_table($table, 'qtype_randomsamatch_options');
+        }
+
+        // Record that qtype_randomsamatch savepoint was reached.
+        upgrade_plugin_savepoint(true, 2013110501, 'qtype', 'randomsamatch');
+    }
+
+    if ($oldversion < 2013110502) {
+
+        // Define key question (foreign) to be dropped form qtype_randomsamatch_options.
+        $table = new xmldb_table('qtype_randomsamatch_options');
+        $field = new xmldb_field('question', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, '0', 'id');
+
+        if ($dbman->field_exists($table, $field)) {
+            // Launch drop key question.
+            $key = new xmldb_key('question', XMLDB_KEY_FOREIGN, array('question'), 'question', array('id'));
+            $dbman->drop_key($table, $key);
+
+            // Launch rename field question.
+            $dbman->rename_field($table, $field, 'questionid');
+
+            $key = new xmldb_key('questionid', XMLDB_KEY_FOREIGN_UNIQUE, array('questionid'), 'question', array('id'));
+            // Launch add key questionid.
+            $dbman->add_key($table, $key);
+        }
+
+        // Record that qtype_randomsamatch savepoint was reached.
+        upgrade_plugin_savepoint(true, 2013110502, 'qtype', 'randomsamatch');
+    }
+
+    if ($oldversion < 2013110503) {
+
+        // Add subcats field.
+        $table = new xmldb_table('qtype_randomsamatch_options');
+
+        // Define field subcats to be added to qtype_randomsamatch_options.
+        $field = new xmldb_field('subcats', XMLDB_TYPE_INTEGER, 2, null,
+                XMLDB_NOTNULL, null, '1', 'choose');
+
+        // Conditionally launch add field subcats.
+        if (!$dbman->field_exists($table, $field)) {
+            $dbman->add_field($table, $field);
+        }
+
+        // Add combined feedback fields.
+        // Define field correctfeedback to be added to qtype_randomsamatch_options.
+        $field = new xmldb_field('correctfeedback', XMLDB_TYPE_TEXT, 'small', null,
+                null, null, null, 'subcats');
+
+        // Conditionally launch add field correctfeedback.
+        if (!$dbman->field_exists($table, $field)) {
+            $dbman->add_field($table, $field);
+
+            // Now fill it with ''.
+            $DB->set_field('qtype_randomsamatch_options', 'correctfeedback', '');
+
+            // Now add the not null constraint.
+            $field = new xmldb_field('correctfeedback', XMLDB_TYPE_TEXT, 'small', null,
+                    XMLDB_NOTNULL, null, null, 'subcats');
+            $dbman->change_field_notnull($table, $field);
+        }
+
+        // Define field correctfeedbackformat to be added to qtype_randomsamatch_options.
+        $field = new xmldb_field('correctfeedbackformat', XMLDB_TYPE_INTEGER, '2', null,
+                XMLDB_NOTNULL, null, '0', 'correctfeedback');
+
+        // Conditionally launch add field correctfeedbackformat.
+        if (!$dbman->field_exists($table, $field)) {
+            $dbman->add_field($table, $field);
+        }
+
+        // Define field partiallycorrectfeedback to be added to qtype_randomsamatch_options.
+        $field = new xmldb_field('partiallycorrectfeedback', XMLDB_TYPE_TEXT, 'small', null,
+                null, null, null, 'correctfeedbackformat');
+
+        // Conditionally launch add field partiallycorrectfeedback.
+        if (!$dbman->field_exists($table, $field)) {
+            $dbman->add_field($table, $field);
+
+            // Now fill it with ''.
+            $DB->set_field('qtype_randomsamatch_options', 'partiallycorrectfeedback', '');
+
+            // Now add the not null constraint.
+            $field = new xmldb_field('partiallycorrectfeedback', XMLDB_TYPE_TEXT, 'small', null,
+                    XMLDB_NOTNULL, null, null, 'correctfeedbackformat');
+            $dbman->change_field_notnull($table, $field);
+        }
+
+        // Define field partiallycorrectfeedbackformat to be added to qtype_randomsamatch_options.
+        $field = new xmldb_field('partiallycorrectfeedbackformat', XMLDB_TYPE_INTEGER, '2', null,
+                XMLDB_NOTNULL, null, '0', 'partiallycorrectfeedback');
+
+        // Conditionally launch add field partiallycorrectfeedbackformat.
+        if (!$dbman->field_exists($table, $field)) {
+            $dbman->add_field($table, $field);
+        }
+
+        // Define field incorrectfeedback to be added to qtype_randomsamatch_options.
+        $field = new xmldb_field('incorrectfeedback', XMLDB_TYPE_TEXT, 'small', null,
+                null, null, null, 'partiallycorrectfeedbackformat');
+
+        // Conditionally launch add field incorrectfeedback.
+        if (!$dbman->field_exists($table, $field)) {
+            $dbman->add_field($table, $field);
+
+            // Now fill it with ''.
+            $DB->set_field('qtype_randomsamatch_options', 'incorrectfeedback', '');
+
+            // Now add the not null constraint.
+            $field = new xmldb_field('incorrectfeedback', XMLDB_TYPE_TEXT, 'small', null,
+                    XMLDB_NOTNULL, null, null, 'partiallycorrectfeedbackformat');
+            $dbman->change_field_notnull($table, $field);
+        }
+
+        // Define field incorrectfeedbackformat to be added to qtype_randomsamatch_options.
+        $field = new xmldb_field('incorrectfeedbackformat', XMLDB_TYPE_INTEGER, '2', null,
+                XMLDB_NOTNULL, null, '0', 'incorrectfeedback');
+
+        // Conditionally launch add field incorrectfeedbackformat.
+        if (!$dbman->field_exists($table, $field)) {
+            $dbman->add_field($table, $field);
+        }
+
+        // Define field shownumcorrect to be added to qtype_randomsamatch_options.
+        $field = new xmldb_field('shownumcorrect', XMLDB_TYPE_INTEGER, '2', null,
+                XMLDB_NOTNULL, null, '0', 'incorrectfeedbackformat');
+
+        // Conditionally launch add field shownumcorrect.
+        if (!$dbman->field_exists($table, $field)) {
+            $dbman->add_field($table, $field);
+        }
+
+        // Record that qtype_randomsamatch savepoint was reached.
+        upgrade_plugin_savepoint(true, 2013110503, 'qtype', 'randomsamatch');
+    }
+    return true;
+}
diff --git a/question/type/randomsamatch/db/upgradelib.php b/question/type/randomsamatch/db/upgradelib.php
new file mode 100644 (file)
index 0000000..f896c05
--- /dev/null
@@ -0,0 +1,243 @@
+<?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/>.
+
+/**
+ * Upgrade library code for the randomsamatch question type.
+ *
+ * @package   qtype_randomsamatch
+ * @copyright 2013 Jean-Michel Vedrine
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+
+defined('MOODLE_INTERNAL') || die();
+
+
+/**
+ * Class for converting attempt data for randomsamatch questions when upgrading
+ * attempts to the new question engine.
+ *
+ * This class is used by the code in question/engine/upgrade/upgradelib.php.
+ *
+ * @copyright 2013 Jean-Michel Vedrine
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class qtype_randomsamatch_qe2_attempt_updater extends question_qtype_attempt_updater {
+    /** @var array of question stems. */
+    protected $stems;
+    /** @var array of question stems format. */
+    protected $stemformat;
+    /** @var array of choices that can be matched to each stem. */
+    protected $choices;
+    /** @var array index of the right choice for each stem. */
+    protected $right;
+    /** @var array id of the right answer for each stem (used by {@link lookup_choice}). */
+    protected $rightanswerid;
+    /** @var array shuffled stem indexes. */
+    protected $stemorder;
+    /** @var array shuffled choice indexes. */
+    protected $choiceorder;
+    /** @var array flipped version of the choiceorder array. */
+    protected $flippedchoiceorder;
+
+    public function question_summary() {
+        return ''; // Done later, after we know which shortanswer questions are used.
+    }
+
+    public function right_answer() {
+        return ''; // Done later, after we know which shortanswer questions are used.
+    }
+
+    /**
+     * Explode the answer saved as a string in state
+     *
+     * @param string $answer comma separated list of dash separated pairs
+     * @return array
+     */
+    protected function explode_answer($answer) {
+        if (!$answer) {
+            return array();
+        }
+        $bits = explode(',', $answer);
+        $selections = array();
+        foreach ($bits as $bit) {
+            list($stem, $choice) = explode('-', $bit);
+            $selections[$stem] = $choice;
+        }
+        return $selections;
+    }
+
+    protected function make_summary($pairs) {
+        $bits = array();
+        foreach ($pairs as $stem => $answer) {
+            $bits[] = $stem . ' -> ' . $answer;
+        }
+        return implode('; ', $bits);
+    }
+
+    /**
+     * Find the index corresponding to a choice
+     *
+     * @param integer $choice
+     * @return integer
+     */
+    protected function lookup_choice($choice) {
+        if (array_key_exists($choice, $this->choices)) {
+            // Easy case: choice is a key in the choices array.
+            return $choice;
+        } else {
+            // But choice can also be the id of a shortanser correct answer
+            // without been a key of the choices array, in that case we need
+            // to first find the shortanswer id, then find the choices index
+            // associated to it.
+            $questionid = array_search($choice, $this->rightanswerid);
+            if ($questionid) {
+                return $this->right[$questionid];
+            }
+        }
+        return null;
+    }
+
+    public function response_summary($state) {
+        $choices = $this->explode_answer($state->answer);
+        if (empty($choices)) {
+            return null;
+        }
+
+        $pairs = array();
+        foreach ($choices as $stemid => $choicekey) {
+            if (array_key_exists($stemid, $this->stems) && $choices[$stemid]) {
+                $choiceid = $this->lookup_choice($choicekey);
+                if ($choiceid) {
+                    $pairs[$this->stems[$stemid]] = $this->choices[$choiceid];
+                } else {
+                    $this->logger->log_assumption("Dealing with a place where the
+                            student selected a choice that was later deleted for
+                            randomsamatch question {$this->question->id}");
+                    $pairs[$this->stems[$stemid]] = '[CHOICE THAT WAS LATER DELETED]';
+                }
+            }
+        }
+
+        if ($pairs) {
+            return $this->make_summary($pairs);
+        } else {
+            return '';
+        }
+    }
+
+    public function was_answered($state) {
+        $choices = $this->explode_answer($state->answer);
+        foreach ($choices as $choice) {
+            if ($choice) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    public function set_first_step_data_elements($state, &$data) {
+        $this->stems = array();
+        $this->stemformat = array();
+        $this->choices = array();
+        $this->right = array();
+        $this->rightanswer = array();
+        $choices = $this->explode_answer($state->answer);
+        $this->stemorder = array();
+        foreach ($choices as $key => $notused) {
+            $this->stemorder[] = $key;
+        }
+        $wrappedquestions = array();
+        // TODO test what happen when some questions are missing.
+        foreach ($this->stemorder as $questionid) {
+            $wrappedquestions[] = $this->load_question($questionid);
+        }
+        foreach ($wrappedquestions as $wrappedquestion) {
+
+            // We only take into account the first correct answer.
+            $foundcorrect = false;
+            foreach ($wrappedquestion->options->answers as $answer) {
+                if ($foundcorrect || $answer->fraction != 1.0) {
+                    unset($wrappedquestion->options->answers[$answer->id]);
+                } else if (!$foundcorrect) {
+                    $foundcorrect = true;
+                    // Store right answer id, so we can use it later in lookup_choice.
+                    $this->rightanswerid[$wrappedquestion->id] = $answer->id;
+                    $key = array_search($answer->answer, $this->choices);
+                    if ($key === false) {
+                        $key = $answer->id;
+                        $this->choices[$key] = $answer->answer;
+                        $data['_choice_' . $key] = $answer->answer;
+                    }
+                    $this->stems[$wrappedquestion->id] = $wrappedquestion->questiontext;
+                    $this->stemformat[$wrappedquestion->id] = $wrappedquestion->questiontextformat;
+                    $this->right[$wrappedquestion->id] = $key;
+                    $this->rightanswer[$wrappedquestion->id] = $answer->answer;
+
+                    $data['_stem_' . $wrappedquestion->id] = $wrappedquestion->questiontext;
+                    $data['_stemformat_' . $wrappedquestion->id] = $wrappedquestion->questiontextformat;
+                    $data['_right_' . $wrappedquestion->id] = $key;
+
+                }
+            }
+        }
+        $this->choiceorder = array_keys($this->choices);
+        // We don't shuffle the choices as that seems unnecessary for old upgraded attempts.
+        $this->flippedchoiceorder = array_combine(
+                array_values($this->choiceorder), array_keys($this->choiceorder));
+
+        $data['_stemorder'] = implode(',', $this->stemorder);
+        $data['_choiceorder'] = implode(',', $this->choiceorder);
+
+        $this->updater->qa->questionsummary = $this->to_text($this->question->questiontext) . ' {' .
+                implode('; ', $this->stems) . '} -> {' . implode('; ', $this->choices) . '}';
+
+        $answer = array();
+        foreach ($this->stems as $key => $stem) {
+            $answer[$stem] = $this->choices[$this->right[$key]];
+        }
+        $this->updater->qa->rightanswer = $this->make_summary($answer);
+    }
+
+    public function supply_missing_first_step_data(&$data) {
+        throw new coding_exception('qtype_randomsamatch_updater::supply_missing_first_step_data ' .
+                'not tested');
+        $data['_stemorder'] = array();
+        $data['_choiceorder'] = array();
+    }
+
+    public function set_data_elements_for_step($state, &$data) {
+        $choices = $this->explode_answer($state->answer);
+
+        foreach ($this->stemorder as $i => $key) {
+            if (empty($choices[$key])) {
+                $data['sub' . $i] = 0;
+                continue;
+            }
+            $choice = $this->lookup_choice($choices[$key]);
+
+            if (array_key_exists($choice, $this->flippedchoiceorder)) {
+                $data['sub' . $i] = $this->flippedchoiceorder[$choice] + 1;
+            } else {
+                $data['sub' . $i] = 0;
+            }
+        }
+    }
+
+    public function load_question($questionid) {
+        return $this->qeupdater->load_question($questionid);
+    }
+}
index 6a41cac..2d255a6 100644 (file)
@@ -17,8 +17,7 @@
 /**
  * Defines the editing form for the randomsamatch question type.
  *
- * @package    qtype
- * @subpackage randomsamatch
+ * @package    qtype_randomsamatch
  * @copyright  2007 Jamie Pratt me@jamiep.org
  * @license    http://www.gnu.org/copyleft/gpl.html GNU Public License
  */
@@ -41,20 +40,37 @@ class qtype_randomsamatch_edit_form extends question_edit_form {
         }
 
         $mform->addElement('select', 'choose',
-                get_string('randomsamatchnumber', 'quiz'), $questionstoselect);
+                get_string('randomsamatchnumber', 'qtype_randomsamatch'), $questionstoselect);
         $mform->setType('feedback', PARAM_RAW);
 
+        $mform->addElement('advcheckbox', 'subcats',
+                get_string('subcats', 'qtype_randomsamatch'), null, null, array(0, 1));
+        $mform->addHelpButton('subcats', 'subcats', 'qtype_randomsamatch');
+        $mform->setDefault('subcats', 1);
+
         $mform->addElement('hidden', 'fraction', 0);
         $mform->setType('fraction', PARAM_RAW);
+
+        $this->add_combined_feedback_fields(true);
+        $this->add_interactive_settings(true, true);
     }
 
     protected function data_preprocessing($question) {
+        $question = parent::data_preprocessing($question);
+        $question = $this->data_preprocessing_combined_feedback($question, true);
+        $question = $this->data_preprocessing_hints($question, true, true);
+
+        if (!empty($question->options)) {
+            $question->choose = $question->options->choose;
+            $question->subcats = $question->options->subcats;
+        }
+
         if (empty($question->name)) {
-            $question->name = get_string('randomsamatch', 'quiz');
+            $question->name = get_string('randomsamatch', 'qtype_randomsamatch');
         }
 
         if (empty($question->questiontext)) {
-            $question->questiontext = get_string('randomsamatchintro', 'quiz');
+            $question->questiontext = get_string('randomsamatchintro', 'qtype_randomsamatch');
         }
         return $question;
     }
@@ -66,12 +82,14 @@ class qtype_randomsamatch_edit_form extends question_edit_form {
     public function validation($data, $files) {
         global $DB;
         $errors = parent::validation($data, $files);
+
         if (isset($data->categorymoveto)) {
             list($category) = explode(',', $data['categorymoveto']);
         } else {
             list($category) = explode(',', $data['category']);
         }
-        $saquestions = question_bank::get_qtype('randomsamatch')->get_sa_candidates($category);
+        $saquestions = question_bank::get_qtype('randomsamatch')->get_available_saquestions_from_category(
+                $category, $data['subcats']);
         $numberavailable = count($saquestions);
         if ($saquestions === false) {
             $a = new stdClass();
index 7875885..82848d9 100644 (file)
@@ -23,6 +23,7 @@
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
 
+$string['insufficientoptions'] = 'Insufficient selection options are available for this question, therefore it is not available in  this quiz. Please inform your teacher.';
 $string['nosaincategory'] = 'There are no short answer questions in the category that you chose \'{$a->catname}\'. Choose a different category, make some questions in this category.';
 $string['notenoughsaincategory'] = 'There is/are only {$a->nosaquestions} short answer questions in the category that you chose \'{$a->catname}\'. Choose a different category, make some more questions in this category or reduce the amount of questions you\'ve selected.';
 $string['pluginname'] = 'Random short-answer matching';
@@ -31,3 +32,8 @@ $string['pluginname_link'] = 'question/type/randomsamatch';
 $string['pluginnameadding'] = 'Adding a Random short-answer matching question';
 $string['pluginnameediting'] = 'Editing a Random short-answer matching question';
 $string['pluginnamesummary'] = 'Like a Matching question, but created randomly from the short answer questions in a particular category.';
+$string['randomsamatchnumber'] = 'Number of questions to select';
+$string['randomsamatch'] = 'Random short-answer matching';
+$string['randomsamatchintro'] = 'For each of the following questions, select the matching answer from the menu.';
+$string['subcats'] = 'Include subcategories';
+$string['subcats_help'] = 'If checked, questions will be choosen from subcategories too.';
diff --git a/question/type/randomsamatch/lib.php b/question/type/randomsamatch/lib.php
new file mode 100644 (file)
index 0000000..110a4fd
--- /dev/null
@@ -0,0 +1,47 @@
+<?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/>.
+
+/**
+ * Serve question type files
+ *
+ * @since      2.0
+ * @package    qtype_randomsamatch
+ * @copyright  2013 Jean-Michel Vedrine
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+
+defined('MOODLE_INTERNAL') || die();
+
+
+/**
+ * Checks file access for random short answer matching questions.
+ * @package  qtype_randomsamatch
+ * @category files
+ * @param stdClass $course course object
+ * @param stdClass $cm course module object
+ * @param stdClass $context context object
+ * @param string $filearea file area
+ * @param array $args extra arguments
+ * @param bool $forcedownload whether or not force download
+ * @param array $options additional options affecting the file serving
+ * @return bool
+ */
+function qtype_randomsamatch_pluginfile($course, $cm, $context, $filearea, $args, $forcedownload, array $options=array()) {
+    global $DB, $CFG;
+    require_once($CFG->libdir . '/questionlib.php');
+    question_pluginfile($course, $context, 'qtype_randomsamatch', $filearea, $args, $forcedownload, $options);
+}
diff --git a/question/type/randomsamatch/question.php b/question/type/randomsamatch/question.php
new file mode 100644 (file)
index 0000000..f773b9e
--- /dev/null
@@ -0,0 +1,140 @@
+<?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/>.
+
+/**
+ * Matching question definition class.
+ *
+ * @package   qtype_randomsamatch
+ * @copyright 2013 Jean-Michel Vedrine
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+
+defined('MOODLE_INTERNAL') || die();
+
+require_once($CFG->dirroot . '/question/type/match/question.php');
+
+/**
+ * Represents a randomsamatch question.
+ *
+ * @copyright 22013 Jean-Michel Vedrine
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class qtype_randomsamatch_question extends qtype_match_question {
+    /** @var qtype_randomsamatch_question_loader helper for loading the shortanswer questions. */
+    public $questionsloader;
+
+    public function start_attempt(question_attempt_step $step, $variant) {
+        $saquestions = $this->questionsloader->load_questions();
+        foreach ($saquestions as $wrappedquestion) {
+            // Store and save stem text and format.
+            $this->stems[$wrappedquestion->id] = $wrappedquestion->questiontext;
+            $this->stemformat[$wrappedquestion->id] = $wrappedquestion->questiontextformat;
+            $step->set_qt_var('_stem_' . $wrappedquestion->id, $this->stems[$wrappedquestion->id]);
+            $step->set_qt_var('_stemformat_' . $wrappedquestion->id, $this->stemformat[$wrappedquestion->id]);
+
+            // Find, store and save right choice id.
+            $key = $this->find_right_answer($wrappedquestion);
+            $this->right[$wrappedquestion->id] = $key;
+            $step->set_qt_var('_right_' . $wrappedquestion->id, $key);
+            // No need to save saquestions, it will be saved by parent class in _stemorder.
+        }
+
+        // Save all the choices.
+        foreach ($this->choices as $key => $answer) {
+            $step->set_qt_var('_choice_' . $key, $answer);
+        }
+
+        parent::start_attempt($step, $variant);
+    }
+
+    /**
+     * Find the corresponding choice id of the first correct answer of a shortanswer question.
+     * choice is added to the randomsamatch question if it doesn't already exist.
+     * @param object $wrappedquestion short answer question.
+     * @return int correct choice id.
+     */
+    public function find_right_answer($wrappedquestion) {
+        // We only take into account *one* (the first) correct answer.
+        while ($answer = array_shift($wrappedquestion->answers)) {
+            if (!question_state::graded_state_for_fraction(
+                    $answer->fraction)->is_incorrect()) {
+                // Store this answer as a choice, only if this is a new one.
+                $key = array_search($answer->answer, $this->choices);
+                if ($key === false) {
+                    $key = $answer->id;
+                    $this->choices[$key] = $answer->answer;
+                }
+                return $key;
+            }
+        }
+        // We should never get there.
+        throw new coding_exception('shortanswerquestionwithoutrightanswer', $wrappedquestion->id);
+
+    }
+
+    public function apply_attempt_state(question_attempt_step $step) {
+        $saquestions = explode(',', $step->get_qt_var('_stemorder'));
+        foreach ($saquestions as $questionid) {
+            $this->stems[$questionid] = $step->get_qt_var('_stem_' . $questionid);
+            $this->stemformat[$questionid] = $step->get_qt_var('_stemformat_' . $questionid);
+            $key = $step->get_qt_var('_right_' . $questionid);
+            $this->right[$questionid] = $key;
+            $this->choices[$key] = $step->get_qt_var('_choice_' . $key);
+        }
+        parent::apply_attempt_state($step);
+    }
+}
+
+/**
+ * This class is responsible for loading the questions that a question needs from the database.
+ *
+ * @copyright  2013 Jean-Michel vedrine
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class qtype_randomsamatch_question_loader {
+    /** @var array hold available shortanswers questionid to choose from. */
+    protected $availablequestions;
+    /** @var int how many questions to load. */
+    protected $choose;
+
+    /**
+     * Constructor
+     * @param array $availablequestions array of available question ids.
+     * @param int $choose how many questions to load.
+     */
+    public function __construct($availablequestions, $choose) {
+        $this->availablequestions = $availablequestions;
+        $this->choose = $choose;
+    }
+
+    /**
+     * Choose and load the desired number of questions.
+     * @return array of short answer questions.
+     */
+    public function load_questions() {
+        if ($this->choose > count($this->availablequestions)) {
+            throw new coding_exception('notenoughtshortanswerquestions');
+        }
+
+        $questionids = draw_rand_array($this->availablequestions, $this->choose);
+        $questions = array();
+        foreach ($questionids as $questionid) {
+            $questions[] = question_bank::load_question($questionid);
+        }
+        return $questions;
+    }
+}
index b54bac1..c05c263 100644 (file)
@@ -17,8 +17,7 @@
 /**
  * Question type class for the randomsamatch question type.
  *
- * @package    qtype
- * @subpackage randomsamatch
+ * @package    qtype_randomsamatch
  * @copyright  1999 onwards Martin Dougiamas  {@link http://moodle.com}
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
@@ -26,6 +25,8 @@
 
 defined('MOODLE_INTERNAL') || die();
 
+require_once($CFG->dirroot . '/question/type/questionbase.php');
+require_once($CFG->dirroot . '/question/type/numerical/question.php');
 
 /**
  * The randomsamatch question type class.
@@ -37,34 +38,30 @@ defined('MOODLE_INTERNAL') || die();
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
 class qtype_randomsamatch extends question_type {
+    /**
+     * Cache of available shortanswer question ids from a particular category.
+     * @var array two-dimensional array. The first key is a category id, the
+     * second key is wether subcategories should be included.
+     */
+    private $availablesaquestionsbycategory = array();
     const MAX_SUBQUESTIONS = 10;
 
-    public function requires_qtypes() {
-        return array('shortanswer', 'match');
-    }
-
     public function is_usable_by_random() {
         return false;
     }
 
     public function get_question_options($question) {
         global $DB;
-        $question->options = $DB->get_record('question_randomsamatch',
-                array('question' => $question->id), '*', MUST_EXIST);
+        parent::get_question_options($question);
+        $question->options = $DB->get_record('qtype_randomsamatch_options',
+                array('questionid' => $question->id));
 
-        // This could be included as a flag in the database. It's already
-        // supported by the code.
-        // Recurse subcategories: 0 = no recursion, 1 = recursion .
-        $question->options->subcats = 1;
         return true;
 
     }
 
     public function save_question_options($question) {
         global $DB;
-        $options = new stdClass();
-        $options->question = $question->id;
-        $options->choose = $question->choose;
 
         if (2 > $question->choose) {
             $result = new stdClass();
@@ -72,174 +69,93 @@ class qtype_randomsamatch extends question_type {
             return $result;
         }
 
-        if ($existing = $DB->get_record('question_randomsamatch',
-                array('question' => $options->question))) {
-            $options->id = $existing->id;
-            $DB->update_record('question_randomsamatch', $options);
-        } else {
-            $DB->insert_record('question_randomsamatch', $options);
+        $context = $question->context;
+
+        // Save the question options.
+        $options = $DB->get_record('qtype_randomsamatch_options', array('questionid' => $question->id));
+        if (!$options) {
+            $options = new stdClass();
+            $options->questionid = $question->id;
+            $options->correctfeedback = '';
+            $options->partiallycorrectfeedback = '';
+            $options->incorrectfeedback = '';
+            $options->id = $DB->insert_record('qtype_randomsamatch_options', $options);
         }
+
+        $options->choose = $question->choose;
+        $options->subcats = $question->subcats;
+        $options = $this->save_combined_feedback_helper($options, $question, $context, true);
+        $DB->update_record('qtype_randomsamatch_options', $options);
+
+        $this->save_hints($question, true);
+
         return true;
     }
 
+    protected function make_hint($hint) {
+        return question_hint_with_parts::load_from_record($hint);
+    }
+
     public function delete_question($questionid, $contextid) {
         global $DB;
-        $DB->delete_records('question_randomsamatch', array('question' => $questionid));
+        $DB->delete_records('qtype_randomsamatch_options', array('questionid' => $questionid));
 
         parent::delete_question($questionid, $contextid);
     }
 
-    public function create_session_and_responses(&$question, &$state, $cmoptions, $attempt) {
-        // Choose a random shortanswer question from the category:
-        // We need to make sure that no question is used more than once in the
-        // quiz. Therfore the following need to be excluded:
-        // 1. All questions that are explicitly assigned to the quiz
-        // 2. All random questions
-        // 3. All questions that are already chosen by an other random question.
-        global $QTYPES, $OUTPUT, $USER;
-        if (!isset($cmoptions->questionsinuse)) {
-            $cmoptions->questionsinuse = $cmoptions->questions;
-        }
+    public function move_files($questionid, $oldcontextid, $newcontextid) {
+        parent::move_files($questionid, $oldcontextid, $newcontextid);
 
-        if ($question->options->subcats) {
-            // Recurse into subcategories.
-            $categorylist = question_categorylist($question->category);
-        } else {
-            $categorylist = array($question->category);
-        }
-
-        $saquestions = $this->get_sa_candidates($categorylist, $cmoptions->questionsinuse);
+        $this->move_files_in_combined_feedback($questionid, $oldcontextid, $newcontextid);
+        $this->move_files_in_hints($questionid, $oldcontextid, $newcontextid);
+    }
 
-        $count  = count($saquestions);
-        $wanted = $question->options->choose;
+    protected function delete_files($questionid, $contextid) {
+        parent::delete_files($questionid, $contextid);
 
-        if ($count < $wanted) {
-            $question->questiontext = "Insufficient selection options are
-                available for this question, therefore it is not available in  this
-                quiz. Please inform your teacher.";
-            // Treat this as a description from this point on.
-            $question->qtype = 'description';
-            return true;
-        }
+        $this->delete_files_in_combined_feedback($questionid, $contextid);
+        $this->delete_files_in_hints($questionid, $contextid);
+    }
 
-        $saquestions =
-         draw_rand_array($saquestions, $question->options->choose); // From bug 1889.
-
-        foreach ($saquestions as $key => $wrappedquestion) {
-            if (!$QTYPES[$wrappedquestion->qtype]
-             ->get_question_options($wrappedquestion)) {
-                return false;
-            }
-
-            // Now we overwrite the $question->options->answers field to only
-            // *one* (the first) correct answer. This loop can be deleted to
-            // take all answers into account (i.e. put them all into the
-            // drop-down menu.
-            $foundcorrect = false;
-            foreach ($wrappedquestion->options->answers as $answer) {
-                if ($foundcorrect || $answer->fraction != 1.0) {
-                    unset($wrappedquestion->options->answers[$answer->id]);
-                } else if (!$foundcorrect) {
-                    $foundcorrect = true;
-                }
-            }
-
-            if (!$QTYPES[$wrappedquestion->qtype]
-             ->create_session_and_responses($wrappedquestion, $state, $cmoptions,
-             $attempt)) {
-                return false;
-            }
-            $wrappedquestion->name_prefix = $question->name_prefix;
-            $wrappedquestion->maxgrade    = $question->maxgrade;
-            $cmoptions->questionsinuse .= ",$wrappedquestion->id";
-            $state->options->subquestions[$key] = clone($wrappedquestion);
-        }
+    protected function initialise_question_instance(question_definition $question, $questiondata) {
+        parent::initialise_question_instance($question, $questiondata);
+        $availablesaquestions = $this->get_available_saquestions_from_category(
+                $question->category, $questiondata->options->subcats);
+        $question->shufflestems = false;
+        $question->stems = array();
+        $question->choices = array();
+        $question->right = array();
+        $this->initialise_combined_feedback($question, $questiondata);
+        $question->questionsloader = new qtype_randomsamatch_question_loader(
+                $availablesaquestions, $questiondata->options->choose);
+    }
 
-        // Shuffle the answers (Do this always because this is a random question type).
-        $subquestionids = array_values(array_map(create_function('$val',
-         'return $val->id;'), $state->options->subquestions));
-        $subquestionids = swapshuffle($subquestionids);
+    public function can_analyse_responses() {
+        return false;
+    }
 
-        // Create empty responses.
-        foreach ($subquestionids as $val) {
-            $state->responses[$val] = '';
+    /**
+     * Get all the usable shortanswer questions from a particular question category.
+     *
+     * @param integer $categoryid the id of a question category.
+     * @param bool $subcategories whether to include questions from subcategories.
+     * @return array of question records.
+     */
+    public function get_available_saquestions_from_category($categoryid, $subcategories) {
+        if (isset($this->availablesaquestionsbycategory[$categoryid][$subcategories])) {
+            return $this->availablesaquestionsbycategory[$categoryid][$subcategories];
         }
-        return true;
-    }
 
-    function restore_session_and_responses(&$question, &$state) {
-        global $DB;
-        global $QTYPES, $OUTPUT;
-        static $wrappedquestions = array();
-        if (empty($state->responses[''])) {
-            $question->questiontext = "Insufficient selection options are
-             available for this question, therefore it is not available in  this
-             quiz. Please inform your teacher.";
-            // Treat this as a description from this point on.
-            $question->qtype = 'description';
+        if ($subcategories) {
+            $categoryids = question_categorylist($categoryid);
         } else {
-            $responses = explode(',', $state->responses['']);
-            $responses = array_map(create_function('$val',
-             'return explode("-", $val);'), $responses);
-
-            // Restore the previous responses.
-            $state->responses = array();
-            foreach ($responses as $response) {
-                $wqid = $response[0];
-                $state->responses[$wqid] = $response[1];
-                if (!isset($wrappedquestions[$wqid])) {
-                    if (!$wrappedquestions[$wqid] = $DB->get_record('question', array('id' => $wqid))) {
-                        echo $OUTPUT->notification("Couldn't get question (id=$wqid)!");
-                        return false;
-                    }
-                    if (!$QTYPES[$wrappedquestions[$wqid]->qtype]
-                     ->get_question_options($wrappedquestions[$wqid])) {
-                        echo $OUTPUT->notification("Couldn't get question options (id=$response[0])!");
-                        return false;
-                    }
-
-                    // Now we overwrite the $question->options->answers field to only
-                    // *one* (the first) correct answer. This loop can be deleted to
-                    // take all answers into account (i.e. put them all into the
-                    // drop-down menu.
-                    $foundcorrect = false;
-                    foreach ($wrappedquestions[$wqid]->options->answers as $answer) {
-                        if ($foundcorrect || $answer->fraction != 1.0) {
-                            unset($wrappedquestions[$wqid]->options->answers[$answer->id]);
-                        } else if (!$foundcorrect) {
-                            $foundcorrect = true;
-                        }
-                    }
-                }
-                $wrappedquestion = clone($wrappedquestions[$wqid]);
-
-                if (!$QTYPES[$wrappedquestion->qtype]
-                 ->restore_session_and_responses($wrappedquestion, $state)) {
-                    echo $OUTPUT->notification("Couldn't restore session of question (id=$response[0])!");
-                    return false;
-                }
-                $wrappedquestion->name_prefix = $question->name_prefix;
-                $wrappedquestion->maxgrade    = $question->maxgrade;
-
-                $state->options->subquestions[$wrappedquestion->id] =
-                 clone($wrappedquestion);
-            }
+            $categoryids = array($categoryid);
         }
-        return true;
-    }
 
-    public function get_sa_candidates($categorylist, $questionsinuse = 0) {
-        global $DB;
-        list ($usql, $params) = $DB->get_in_or_equal($categorylist);
-        list ($ques_usql, $ques_params) = $DB->get_in_or_equal(explode(',', $questionsinuse),
-                SQL_PARAMS_QM, null, false);
-        $params = array_merge($params, $ques_params);
-        return $DB->get_records_select('question',
-         "qtype = 'shortanswer' " .
-         "AND category $usql " .
-         "AND parent = '0' " .
-         "AND hidden = '0'" .
-         "AND id $ques_usql", $params);
+        $questionids = question_bank::get_finder()->get_questions_from_categories(
+                $categoryids, "qtype = 'shortanswer'");
+        $this->availablesaquestionsbycategory[$categoryid][$subcategories] = $questionids;
+        return $questionids;
     }
 
     /**
@@ -251,4 +167,68 @@ class qtype_randomsamatch extends question_type {
     public function get_random_guess_score($question) {
         return 1/$question->options->choose;
     }
+
+    /**
+     * Defines the table which extends the question table. This allows the base questiontype
+     * to automatically save, backup and restore the extra fields.
+     *
+     * @return an array with the table name (first) and then the column names (apart from id and questionid)
+     */
+    public function extra_question_fields() {
+        return array('qtype_randomsamatch_options',
+                     'choose',        // Number of shortanswer questions to choose.
+                     'subcats',       // Questions can be choosen from subcategories.
+                     );
+    }
+
+    /**
+     * Imports the question from Moodle XML format.
+     *
+     * @param array $xml structure containing the XML data
+     * @param object $fromform question object to fill: ignored by this function (assumed to be null)
+     * @param qformat_xml $format format class exporting the question
+     * @param object $extra extra information (not required for importing this question in this format)
+     * @return object question object
+     */
+    public function import_from_xml($xml, $fromform, qformat_xml $format, $extra=null) {
+        // Return if data type is not our own one.
+        if (!isset($xml['@']['type']) || $xml['@']['type'] != $this->name()) {
+            return false;
+        }
+
+        // Import the common question headers and set the corresponding field.
+        $fromform = $format->import_headers($xml);
+        $fromform->qtype = $this->name();
+        $format->import_combined_feedback($fromform, $xml, true);
+        $format->import_hints($fromform, $xml, true);
+
+        $extras = $this->extra_question_fields();
+        array_shift($extras);
+        foreach ($extras as $extra) {
+            $fromform->$extra = $format->getpath($xml, array('#', $extra, 0, '#'), '', true);
+        }
+
+        return $fromform;
+    }
+
+    /**
+     * Exports the question to Moodle XML format.
+     *
+     * @param object $question question to be exported into XML format
+     * @param qformat_xml $format format class exporting the question
+     * @param object $extra extra information (not required for exporting this question in this format)
+     * @return string containing the question data in XML format
+     */
+    public function export_to_xml($question, qformat_xml $format, $extra=null) {
+        $expout = '';
+        $expout .= $format->write_combined_feedback($question->options,
+                                                    $question->id,
+                                                    $question->contextid);
+        $extraquestionfields = $this->extra_question_fields();
+        array_shift($extraquestionfields);
+        foreach ($extraquestionfields as $extra) {
+            $expout .= "    <$extra>" . $question->options->$extra . "</$extra>\n";
+        }
+        return $expout;
+    }
 }
diff --git a/question/type/randomsamatch/renderer.php b/question/type/randomsamatch/renderer.php
new file mode 100644 (file)
index 0000000..28b803f
--- /dev/null
@@ -0,0 +1,43 @@
+<?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/>.
+
+/**
+ * Matching question renderer class.
+ *
+ * @package   qtype_randomsamatch
+ * @copyright 2013 Jean-Michel Vedrine
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+
+defined('MOODLE_INTERNAL') || die();
+
+require_once($CFG->dirroot . '/question/type/match/renderer.php');
+
+/**
+ * Generates the output for randomsamatch questions.
+ *
+ * @copyright 2013 Jean-Michel Vedrine
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class qtype_randomsamatch_renderer extends qtype_match_renderer {
+    public function format_stem_text($qa, $stemid) {
+        $question = $qa->get_question();
+        return $question->format_text(
+                    $question->stems[$stemid], $question->stemformat[$stemid],
+                    $qa, 'question', 'questiontext', $stemid);
+    }
+}
diff --git a/question/type/randomsamatch/tests/helper.php b/question/type/randomsamatch/tests/helper.php
new file mode 100644 (file)
index 0000000..7b26e05
--- /dev/null
@@ -0,0 +1,139 @@
+<?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/>.
+
+/**
+ * Test helpers for the randomsamatch question type.
+ *
+ * @package    qtype_randomsamatch
+ * @copyright  2013 Jean-Michel Vedrine
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+
+defined('MOODLE_INTERNAL') || die();
+
+global $CFG;
+require_once($CFG->dirroot . '/question/type/randomsamatch/question.php');
+require_once($CFG->dirroot . '/question/engine/tests/helpers.php');
+
+
+/**
+ * Test helper class for the randomsamatch question type.
+ *
+ * @copyright  2013 Jean-Michel Vedrine
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class qtype_randomsamatch_test_helper extends question_test_helper {
+    public function get_test_questions() {
+        return array('animals');
+    }
+
+    /**
+     * Makes a randomsamatch question similar to the match question returned
+     * by {@link make_a_matching_question}, but with no 'insect' distractor.
+     * @return qtype_randomsamatch_question
+     */
+    public function make_randomsamatch_question_animals() {
+        question_bank::load_question_definition_classes('randomsamatch');
+        $q = new qtype_randomsamatch_question();
+        test_question_maker::initialise_a_question($q);
+        $q->name = 'Random shortanswer matching question';
+        $q->questiontext = 'Classify the animals.';
+        $q->generalfeedback = 'Frogs and toads are amphibians, the others are mammals.';
+        $q->qtype = question_bank::get_qtype('randomsamatch');
+        test_question_maker::set_standard_combined_feedback_fields($q);
+        $q->shufflestems = false;
+        $q->stems = array();
+        $q->choices = array();
+        $q->right = array();
+        // Now we create 4 shortanswers question,
+        // but we just fill the needed fields.
+        question_bank::load_question_definition_classes('shortanswer');
+        $sa1 = new qtype_shortanswer_question();
+        test_question_maker::initialise_a_question($sa1);
+        $sa1->id = 25;
+        $sa1->questiontext = 'Dog';
+        $sa1->answers = array(
+            13 => new question_answer(13, 'Mammal', 1.0, 'Correct.', FORMAT_HTML),
+            14 => new question_answer(14, 'Animal', 0.5, 'There is a betterresponse.', FORMAT_HTML),
+            15 => new question_answer(15, '*', 0.0, 'That is a bad answer.', FORMAT_HTML),
+        );
+        $sa1->qtype = question_bank::get_qtype('shortanswer');
+
+        $sa2 = new qtype_shortanswer_question();
+        test_question_maker::initialise_a_question($sa2);
+        $sa2->id = 26;
+        $sa2->questiontext = 'Frog';
+        $sa2->answers = array(
+            16 => new question_answer(16, 'Amphibian', 1.0, 'Correct.', FORMAT_HTML),
+            17 => new question_answer(17, 'A Prince', 1.0, 'Maybe.', FORMAT_HTML),
+            18 => new question_answer(18, '*', 0.0, 'That is a bad answer.', FORMAT_HTML),
+        );
+        $sa2->qtype = question_bank::get_qtype('shortanswer');
+
+        $sa3 = new qtype_shortanswer_question();
+        test_question_maker::initialise_a_question($sa3);
+        $sa3->id = 27;
+        $sa3->questiontext = 'Toad';
+        $sa3->answers = array(
+            19 => new question_answer(19, 'Amphibian', 1.0, 'Correct.', FORMAT_HTML),
+            20 => new question_answer(20, '*', 0.0, 'That is a bad answer.', FORMAT_HTML),
+        );
+        $sa3->qtype = question_bank::get_qtype('shortanswer');
+
+        $sa4 = new qtype_shortanswer_question();
+        test_question_maker::initialise_a_question($sa4);
+        $sa4->id = 28;
+        $sa4->questiontext = 'Cat';
+        $sa4->answers = array(
+            21 => new question_answer(21, 'Mammal', 1.0, 'Correct.', FORMAT_HTML),
+        );
+        $sa4->qtype = question_bank::get_qtype('shortanswer');
+        $q->questionsloader = new qtype_randomsamatch_test_question_loader(array(), 4, array($sa1, $sa2, $sa3, $sa4));
+        return $q;
+    }
+}
+
+/**
+ * Test implementation of {@link qtype_randomsamatch_question_loader}. Gets the questions
+ * from an array passed to the constructor, rather than querying the database.
+ *
+ * @copyright  2013 Jean-Michel Vedrine
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class qtype_randomsamatch_test_question_loader extends qtype_randomsamatch_question_loader {
+    /** @var array hold available shortanswers questions to choose from. */
+    protected $questions;
+
+    /**
+     * Constructor
+     * @param array $availablequestions not used for tests.
+     * @param int $choose how many questions to load (not used here).
+     * @param array $questions array of questions to use.
+     */
+    public function __construct($availablequestions, $choose, $questions) {
+        parent::__construct($availablequestions, $choose);
+        $this->questions = $questions;
+    }
+
+    /**
+     * Just return the shortanswers questions passed to the constructor.
+     * @return array of short answer questions.
+     */
+    public function load_questions() {
+        return $this->questions;
+    }
+}
diff --git a/question/type/randomsamatch/tests/question_test.php b/question/type/randomsamatch/tests/question_test.php
new file mode 100644 (file)
index 0000000..cbe8a8e
--- /dev/null
@@ -0,0 +1,153 @@
+<?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/>.
+
+/**
+ * Unit tests for the radom shortanswer matching question definition classes.
+ *
+ * @package   qtype_randomsamatch
+ * @copyright 2013 Jean-Michel Vedrine
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+
+defined('MOODLE_INTERNAL') || die();
+
+global $CFG;
+require_once($CFG->dirroot . '/question/engine/tests/helpers.php');
+
+
+/**
+ * Unit tests for the random shortanswer matching question definition class.
+ *
+ * @copyright 2013 Jean-Michel Vedrine
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class qtype_randomsamatch_question_test extends advanced_testcase {
+
+    public function test_get_expected_data() {
+        $question = test_question_maker::make_question('randomsamatch');
+        $question->start_attempt(new question_attempt_step(), 1);
+
+        $this->assertEquals(array('sub0' => PARAM_INT, 'sub1' => PARAM_INT,
+                'sub2' => PARAM_INT, 'sub3' => PARAM_INT), $question->get_expected_data());
+    }
+
+    public function test_is_complete_response() {
+        $question = test_question_maker::make_question('randomsamatch');
+        $question->start_attempt(new question_attempt_step(), 1);
+
+        $this->assertFalse($question->is_complete_response(array()));
+        $this->assertFalse($question->is_complete_response(
+                array('sub0' => '1', 'sub1' => '1', 'sub2' => '1', 'sub3' => '0')));
+        $this->assertFalse($question->is_complete_response(array('sub1' => '1')));
+        $this->assertTrue($question->is_complete_response(
+                array('sub0' => '1', 'sub1' => '1', 'sub2' => '1', 'sub3' => '1')));
+    }
+
+    public function test_is_gradable_response() {
+        $question = test_question_maker::make_question('randomsamatch');
+        $question->start_attempt(new question_attempt_step(), 1);
+
+        $this->assertFalse($question->is_gradable_response(array()));
+        $this->assertFalse($question->is_gradable_response(
+                array('sub0' => '0', 'sub1' => '0', 'sub2' => '0', 'sub3' => '0')));
+        $this->assertTrue($question->is_gradable_response(
+                array('sub0' => '1', 'sub1' => '0', 'sub2' => '0', 'sub3' => '0')));
+        $this->assertTrue($question->is_gradable_response(array('sub1' => '1')));
+        $this->assertTrue($question->is_gradable_response(
+                array('sub0' => '1', 'sub1' => '1', 'sub2' => '3', 'sub3' => '1')));
+    }
+
+    public function test_is_same_response() {
+        $question = test_question_maker::make_question('randomsamatch');
+        $question->start_attempt(new question_attempt_step(), 1);
+
+        $this->assertTrue($question->is_same_response(
+                array(),
+                array('sub0' => '0', 'sub1' => '0', 'sub2' => '0', 'sub3' => '0')));
+
+        $this->assertTrue($question->is_same_response(
+                array('sub0' => '0', 'sub1' => '0', 'sub2' => '0', 'sub3' => '0'),
+                array('sub0' => '0', 'sub1' => '0', 'sub2' => '0', 'sub3' => '0')));
+
+        $this->assertFalse($question->is_same_response(
+                array('sub0' => '0', 'sub1' => '0', 'sub2' => '0', 'sub3' => '0'),
+                array('sub0' => '1', 'sub1' => '2', 'sub2' => '3', 'sub3' => '1')));
+
+        $this->assertTrue($question->is_same_response(
+                array('sub0' => '1', 'sub1' => '2', 'sub2' => '3', 'sub3' => '1'),
+                array('sub0' => '1', 'sub1' => '2', 'sub2' => '3', 'sub3' => '1')));
+
+        $this->assertFalse($question->is_same_response(
+                array('sub0' => '2', 'sub1' => '2', 'sub2' => '3', 'sub3' => '1'),
+                array('sub0' => '1', 'sub1' => '2', 'sub2' => '3', 'sub3' => '1')));
+    }
+
+    public function test_grading() {
+        $question = test_question_maker::make_question('randomsamatch');
+        $question->start_attempt(new question_attempt_step(), 1);
+
+        $choiceorder = $question->get_choice_order();
+        $orderforchoice = array_combine(array_values($choiceorder), array_keys($choiceorder));
+        $this->assertEquals(array(1, question_state::$gradedright),
+                $question->grade_response(array('sub0' => $orderforchoice[13],
+                        'sub1' => $orderforchoice[16], 'sub2' => $orderforchoice[16],
+                        'sub3' => $orderforchoice[13])));
+        $this->assertEquals(array(0.25, question_state::$gradedpartial),
+                $question->grade_response(array('sub0' => $orderforchoice[13])));
+        $this->assertEquals(array(0, question_state::$gradedwrong),
+                $question->grade_response(array('sub0' => $orderforchoice[16],
+                        'sub1' => $orderforchoice[13], 'sub2' => $orderforchoice[13],
+                        'sub3' => $orderforchoice[16])));
+    }
+
+    public function test_get_correct_response() {
+        $question = test_question_maker::make_question('randomsamatch');
+        $question->start_attempt(new question_attempt_step(), 1);
+
+        $choiceorder = $question->get_choice_order();
+        $orderforchoice = array_combine(array_values($choiceorder), array_keys($choiceorder));
+
+        $this->assertEquals(array('sub0' => $orderforchoice[13], 'sub1' => $orderforchoice[16],
+                'sub2' => $orderforchoice[16], 'sub3' => $orderforchoice[13]),
+                $question->get_correct_response());
+    }
+
+    public function test_get_question_summary() {
+        $question = test_question_maker::make_question('randomsamatch');
+        $question->start_attempt(new question_attempt_step(), 1);
+        $qsummary = $question->get_question_summary();
+        $this->assertRegExp('/' . preg_quote($question->questiontext, '/') . '/', $qsummary);
+        foreach ($question->stems as $stem) {
+            $this->assertRegExp('/' . preg_quote($stem, '/') . '/', $qsummary);
+        }
+        foreach ($question->choices as $choice) {
+            $this->assertRegExp('/' . preg_quote($choice, '/') . '/', $qsummary);
+        }
+    }
+
+    public function test_summarise_response() {
+        $question = test_question_maker::make_question('randomsamatch');
+        $question->shufflestems = false;
+        $question->start_attempt(new question_attempt_step(), 1);
+
+        $summary = $question->summarise_response(array('sub0' => 2, 'sub1' => 1));
+
+        $this->assertRegExp('/Dog -> \w+; Frog -> \w+/', $summary);
+    }
+
+
+}
diff --git a/question/type/randomsamatch/tests/upgradelibnewqe_test.php b/question/type/randomsamatch/tests/upgradelibnewqe_test.php
new file mode 100644 (file)
index 0000000..86f5bb1
--- /dev/null
@@ -0,0 +1,406 @@
+<?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/>.
+
+/**
+ * Tests of the upgrade to the new Moodle question engine for attempts at
+ * randomsamatch questions.
+ *
+ * @package    qtype_randomsamatch
+ * @copyright  2013 Jean-Michel Vedrine
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+
+defined('MOODLE_INTERNAL') || die();
+
+global $CFG;
+require_once($CFG->dirroot . '/question/engine/upgrade/tests/helper.php');
+
+
+/**
+ * Testing the upgrade of randomsamatch question attempts.
+ *
+ * @copyright  2013 Jean-Michel Vedrine
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class qtype_randomsamatch_attempt_upgrader_test extends question_attempt_upgrader_test_base {
+    public function test_randomsamatch_deferredfeedback_qsession1() {
+        $quiz = (object) array(
+            'id' => '1',
+            'course' => '2',
+            'name' => 'random short answer matching deferred quiz',
+            'intro' => '<p>To test random shortanswer matching questions.</p>',
+            'introformat' => '1',
+            'timeopen' => '0',
+            'timeclose' => '0',
+            'attempts' => '0',
+            'attemptonlast' => '0',
+            'grademethod' => '1',
+            'decimalpoints' => '2',
+            'questiondecimalpoints' => '-1',
+            'review' => '4459503',
+            'questionsperpage' => '1',
+            'shufflequestions' => '0',
+            'shuffleanswers' => '1',
+            'questions' => '5,0',
+            'sumgrades' => '1.00000',
+            'grade' => '100.00000',
+            'timecreated' => '0',
+            'timemodified' => '1368446711',
+            'timelimit' => '0',
+            'password' => '',
+            'subnet' => '',
+            'popup' => '0',
+            'delay1' => '0',
+            'delay2' => '0',
+            'showuserpicture' => '0',
+            'showblocks' => '0',
+            'preferredbehaviour' => 'deferredfeedback',
+        );
+        $attempt = (object) array(
+            'id' => '1',
+            'uniqueid' => '1',
+            'quiz' => '1',
+            'userid' => '3',
+            'attempt' => '1',
+            'sumgrades' => '0.66667',
+            'timestart' => '1368446755',
+            'timefinish' => '1368446789',
+            'timemodified' => '1368446789',
+            'layout' => '5,0',
+            'preview' => '0',
+            'needsupgradetonewqe' => 1,
+        );
+        $question = (object) array(
+            'id' => '5',
+            'category' => '1',
+            'parent' => '0',
+            'name' => 'Random shortanswer matching question animals',
+            'questiontext' => 'For each of the following questions, select the matching answer from the menu.',
+            'questiontextformat' => '1',
+            'generalfeedback' => '',
+            'generalfeedbackformat' => '1',
+            'penalty' => '0.1000000',
+            'qtype' => 'randomsamatch',
+            'length' => '1',
+            'stamp' => 'localhost+130513115611+72Efbk',
+            'version' => 'localhost+130513115611+0REXHW',
+            'hidden' => '0',
+            'timecreated' => '1368446171',
+            'timemodified' => '1368446171',
+            'createdby' => '2',
+            'modifiedby' => '2',
+            'maxmark' => '1.0000000',
+            'options' => (object) array(
+                'id' => '1',
+                'question' => '5',
+                'choose' => '3',
+                'subcats' => 1,
+            ),
+            'defaultmark' => '1.0000000',
+        );
+        $qsession = (object) array(
+            'id' => '1',
+            'attemptid' => '1',
+            'questionid' => '5',
+            'newest' => '3',
+            'newgraded' => '3',
+            'sumpenalty' => '0.1000000',
+            'manualcomment' => '',
+            'manualcommentformat' => '1',
+            'flagged' => '0',
+        );
+        $qstates = array(
+            1 => (object) array(
+                'id' => '1',
+                'attempt' => '1',
+                'question' => '5',
+                'seq_number' => '0',
+                'answer' => '2-0,3-0,6-0',
+                'timestamp' => '1368446755',
+                'event' => '0',
+                'grade' => '0.0000000',
+                'raw_grade' => '0.0000000',
+                'penalty' => '0.0000000',
+            ),
+            2 => (object) array(
+                'id' => '2',
+                'attempt' => '1',
+                'question' => '5',
+                'seq_number' => '1',
+                'answer' => '2-3,3-5,6-3',
+                'timestamp' => '1368446783',
+                'event' => '2',
+                'grade' => '0.0000000',
+                'raw_grade' => '0.6666667',
+                'penalty' => '0.1000000',
+            ),
+            3 => (object) array(
+                'id' => '3',
+                'attempt' => '1',
+                'question' => '5',
+                'seq_number' => '2',
+                'answer' => '2-3,3-5,6-3',
+                'timestamp' => '1368446783',
+                'event' => '6',
+                'grade' => '0.6666667',
+                'raw_grade' => '0.6666667',
+                'penalty' => '0.1000000',
+            ),
+        );
+        $sa1 = (object) array(
+            'id' => '2',
+            'category' => '1',
+            'parent' => '0',
+            'name' => 'animal 1',
+            'questiontext' => 'Dog',
+            'questiontextformat' => '1',
+            'defaultmark' => '1',
+            'penalty' => '0.1',
+            'qtype' => 'shortanswer',
+            'length' => '1',
+            'stamp' => 'localhost+090227173002+mbdE0X',
+            'version' => 'localhost+090304190917+xAB5Nf',
+            'hidden' => '0',
+            'generalfeedback' => '',
+            'generalfeedbackformat' => '1',
+            'timecreated' => '1235755802',
+            'timemodified' => '1236193757',
+            'createdby' => '25299',
+            'modifiedby' => '25299',
+            'unlimited' => '0',
+            'options' => (object) array(
+                'id' => '15211',
+                'question' => '2',
+                'layout' => '0',
+                'answers' => array(
+                    7 => (object) array(
+                        'question' => '2',
+                        'answer' => 'Amphibian',
+                        'fraction' => '0',
+                        'feedback' => '',
+                        'id' => 7,
+                    ),
+                    3 => (object) array(
+                        'question' => '2',
+                        'answer' => 'Mammal',
+                        'fraction' => '1',
+                        'feedback' => '',
+                        'id' => 3,
+                    ),
+                    22 => (object) array(
+                        'question' => '2',
+                        'answer' => '*',
+                        'fraction' => '0',
+                        'feedback' => '',
+                        'id' => 22,
+                    ),
+                ),
+                'single' => '1',
+                'shuffleanswers' => '1',
+                'correctfeedback' => 'Your answer is correct. Well done.',
+                'partiallycorrectfeedback' => '',
+                'incorrectfeedback' => 'Your answer is incorrect. The correct answer is: Mammal.',
+                'answernumbering' => 'abc',
+            ),
+        );
+
+        $sa2 = (object) array(
+            'id' => '3',
+            'category' => '1',
+            'parent' => '0',
+            'name' => 'animal 2',
+            'questiontext' => 'Frog',
+            'questiontextformat' => '1',
+            'defaultmark' => '1',
+            'penalty' => '0.1',
+            'qtype' => 'shortanswer',
+            'length' => '1',
+            'stamp' => 'localhost+090227173002+mbdE0X',
+            'version' => 'localhost+090304190917+xAB5Nf',
+            'hidden' => '0',
+            'generalfeedback' => '',
+            'generalfeedbackformat' => '1',
+            'timecreated' => '1235755802',
+            'timemodified' => '1236193757',
+            'createdby' => '25299',
+            'modifiedby' => '25299',
+            'unlimited' => '0',
+            'options' => (object) array(
+                'id' => '15214',
+                'question' => '3',
+                'layout' => '0',
+                'answers' => array(
+                    5 => (object) array(
+                        'question' => '3',
+                        'answer' => 'Amphibian',
+                        'fraction' => '1',
+                        'feedback' => '',
+                        'id' => 5,
+                    ),
+                    11 => (object) array(
+                        'question' => '3',
+                        'answer' => 'Mammal',
+                        'fraction' => '0',
+                        'feedback' => '',
+                        'id' => 11,
+                    ),
+                    27 => (object) array(
+                        'question' => '3',
+                        'answer' => '*',
+                        'fraction' => '0',
+                        'feedback' => '',
+                        'id' => 27,
+                    ),
+                ),
+                'single' => '1',
+                'shuffleanswers' => '1',
+                'correctfeedback' => 'Your answer is correct. Well done.',
+                'partiallycorrectfeedback' => '',
+                'incorrectfeedback' => 'Your answer is incorrect. The correct answer is: Mammal.',
+                'answernumbering' => 'abc',
+            ),
+        );
+
+        $sa3 = (object) array(
+            'id' => '6',
+            'category' => '1',
+            'parent' => '0',
+            'name' => 'animal 3',
+            'questiontext' => 'Toad',
+            'questiontextformat' => '1',
+            'defaultmark' => '1',
+            'penalty' => '0.1',
+            'qtype' => 'shortanswer',
+            'length' => '1',
+            'stamp' => 'localhost+090227173002+mbdE0X',
+            'version' => 'localhost+090304190917+xAB5Nf',
+            'hidden' => '0',
+            'generalfeedback' => '',
+            'generalfeedbackformat' => '1',
+            'timecreated' => '1235755802',
+            'timemodified' => '1236193757',
+            'createdby' => '25299',
+            'modifiedby' => '25299',
+            'unlimited' => '0',
+            'options' => (object) array(
+                'id' => '4578',
+                'question' => '6',
+                'layout' => '0',
+                'answers' => array(
+                    9 => (object) array(
+                        'question' => '6',
+                        'answer' => 'Amphibian',
+                        'fraction' => '1',
+                        'feedback' => '',
+                        'id' => 9,
+                    ),
+                    18 => (object) array(
+                        'question' => '6',
+                        'answer' => 'Mammal',
+                        'fraction' => '0',
+                        'feedback' => '',
+                        'id' => 18,
+                    ),
+                    32 => (object) array(
+                        'question' => '6',
+                        'answer' => '*',
+                        'fraction' => '0',
+                        'feedback' => '',
+                        'id' => 32,
+                    ),
+                ),
+                'single' => '1',
+                'shuffleanswers' => '1',
+                'correctfeedback' => 'Your answer is correct. Well done.',
+                'partiallycorrectfeedback' => '',
+                'incorrectfeedback' => 'Your answer is incorrect. The correct answer is: Mammal.',
+                'answernumbering' => 'abc',
+            ),
+        );
+
+        $this->loader->put_question_in_cache($sa2);
+        $this->loader->put_question_in_cache($sa1);
+        $this->loader->put_question_in_cache($sa3);
+        $qa = $this->updater->convert_question_attempt($quiz, $attempt, $question, $qsession, $qstates);
+
+        $expectedqa = (object) array(
+            'behaviour' => 'deferredfeedback',
+            'questionid' => 5,
+            'variant' => 1,
+            'maxmark' => 1.0000000,
+            'minfraction' => 0,
+            'maxfraction' => 1,
+            'flagged' => 0,
+            'questionsummary' => 'For each of the following questions, select the matching answer from the menu.{Dog;Frog;Toad}->{Mammal;Amphibian}',
+            'rightanswer' => 'Dog -> Mammal; Frog -> Amphibian; Toad -> Amphibian',
+            'responsesummary' => 'Dog->Mammal;Frog->Amphibian;Toad->Mammal',
+            'timemodified' => 1368446783,
+            'steps' => array(
+                0 => (object) array(
+                    'sequencenumber' => 0,
+                    'state' => 'todo',
+                    'fraction' => null,
+                    'timecreated' => 1368446755,
+                    'userid' => 3,
+                    'data' => array(
+                        '_choice_3' => 'Mammal',
+                        '_stem_2' => 'Dog',
+                        '_stemformat_2' => '1',
+                        '_right_2' => 3,
+                        '_choice_5' => 'Amphibian',
+                        '_stem_3' => 'Frog',
+                        '_stemformat_3' => '1',
+                        '_right_3' => 5,
+                        '_stem_6' => 'Toad',
+                        '_stemformat_6' => '1',
+                        '_right_6' => 5,
+                        '_stemorder' => '2,3,6',
+                        '_choiceorder' => '3,5',
+                    ),
+                ),
+                1 => (object) array(
+                    'sequencenumber' => 1,
+                    'state' => 'complete',
+                    'fraction' => null,
+                    'timecreated' => 1368446783,
+                    'userid' => 3,
+                    'data' => array(
+                        'sub0' => 1,
+                        'sub1' => 2,
+                        'sub2' => 1,
+                    ),
+                ),
+                2 => (object) array(
+                    'sequencenumber' => 2,
+                    'state' => 'gradedpartial',
+                    'fraction' => 0.6666667,
+                    'timecreated' => 1368446783,
+                    'userid' => 3,
+                    'data' => array(
+                        'sub0' => 1,
+                        'sub1' => 2,
+                        'sub2' => 1,
+                        '-finish' => 1,
+
+                    ),
+                ),
+            ),
+        );
+
+        $this->compare_qas($expectedqa, $qa);
+    }
+}
\ No newline at end of file
diff --git a/question/type/randomsamatch/tests/walkthrough_test.php b/question/type/randomsamatch/tests/walkthrough_test.php
new file mode 100644 (file)
index 0000000..07c2643
--- /dev/null
@@ -0,0 +1,435 @@
+<?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 tests that walks a question through the interactive
+ * behaviour.
+ *
+ * @package   qtype_randomsamatch
+ * @copyright 2013 Jean-Michel Vedrine
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+
+defined('MOODLE_INTERNAL') || die();
+
+global $CFG;
+require_once($CFG->dirroot . '/question/engine/tests/helpers.php');
+
+
+/**
+ * Unit tests for the randomsamatch question type.
+ *
+ * @copyright 2013 Jean-Michel Vedrine
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class qtype_randomsamatch_walkthrough_test extends qbehaviour_walkthrough_test_base {
+
+    public function test_deferred_feedback_unanswered() {
+
+        // Create a randomsamatch question.
+        $m = test_question_maker::make_question('randomsamatch');
+        $this->start_attempt_at_question($m, 'deferredfeedback', 4);
+
+        $choiceorder = $m->get_choice_order();
+        $orderforchoice = array_combine(array_values($choiceorder), array_keys($choiceorder));
+        $choices = array(0 => get_string('choose') . '...');
+        foreach ($choiceorder as $key => $choice) {
+            $choices[$key] = $m->choices[$choice];
+        }
+
+        // Check the initial state.
+        $this->check_current_state(question_state::$todo);
+        $this->check_current_mark(null);
+        $this->check_current_output(
+                $this->get_contains_select_expectation('sub0', $choices, null, true),
+                $this->get_contains_select_expectation('sub1', $choices, null, true),
+                $this->get_contains_select_expectation('sub2', $choices, null, true),
+                $this->get_contains_select_expectation('sub3', $choices, null, true),
+                $this->get_contains_question_text_expectation($m),
+                $this->get_does_not_contain_feedback_expectation());
+        $this->check_step_count(1);
+
+        // Save a blank response.
+        $this->process_submission(array('sub0' => '0', 'sub1' => '0',
+                'sub2' => '0', 'sub3' => '0'));
+
+        // Verify.
+        $this->check_current_state(question_state::$todo);
+        $this->check_current_mark(null);
+        $this->check_current_output(
+                $this->get_contains_select_expectation('sub0', $choices, null, true),
+                $this->get_contains_select_expectation('sub1', $choices, null, true),
+                $this->get_contains_select_expectation('sub2', $choices, null, true),
+                $this->get_contains_select_expectation('sub3', $choices, null, true),
+                $this->get_contains_question_text_expectation($m),
+                $this->get_does_not_contain_feedback_expectation());
+        $this->check_step_count(1);
+
+        // Finish the attempt.
+        $this->quba->finish_all_questions();
+
+        // Verify.
+        $this->check_current_state(question_state::$gaveup);
+        $this->check_current_mark(null);
+        $this->check_current_output(
+                $this->get_contains_select_expectation('sub0', $choices, null, false),
+                $this->get_contains_select_expectation('sub1', $choices, null, false),
+                $this->get_contains_select_expectation('sub2', $choices, null, false),
+                $this->get_contains_select_expectation('sub3', $choices, null, false));
+    }
+
+    public function test_deferred_feedback_partial_answer() {
+
+        // Create a randomsamatching question.
+        $m = test_question_maker::make_question('randomsamatch');
+        $m->shufflestems = false;
+        $this->start_attempt_at_question($m, 'deferredfeedback', 4);
+
+        $choiceorder = $m->get_choice_order();
+        $orderforchoice = array_combine(array_values($choiceorder), array_keys($choiceorder));
+        $choices = array(0 => get_string('choose') . '...');
+        foreach ($choiceorder as $key => $choice) {
+            $choices[$key] = $m->choices[$choice];
+        }
+
+        // Check the initial state.
+        $this->check_current_state(question_state::$todo);
+        $this->check_current_mark(null);
+        $this->check_current_output(
+                $this->get_contains_select_expectation('sub0', $choices, null, true),
+                $this->get_contains_select_expectation('sub1', $choices, null, true),
+                $this->get_contains_select_expectation('sub2', $choices, null, true),
+                $this->get_contains_select_expectation('sub3', $choices, null, true),
+                $this->get_contains_question_text_expectation($m),
+                $this->get_does_not_contain_feedback_expectation());
+
+        // Save a partial response.
+        $this->process_submission(array('sub0' => $orderforchoice[13],
+                'sub1' => $orderforchoice[16], 'sub2' => '0', 'sub3' => '0'));
+
+        // Verify.
+        $this->check_current_state(question_state::$invalid);
+        $this->check_current_mark(null);
+        $this->check_current_output(
+                $this->get_contains_select_expectation('sub0', $choices, $orderforchoice[13], true),
+                $this->get_contains_select_expectation('sub1', $choices, $orderforchoice[16], true),
+                $this->get_contains_select_expectation('sub2', $choices, null, true),
+                $this->get_contains_select_expectation('sub3', $choices, null, true),
+                $this->get_contains_question_text_expectation($m),
+                $this->get_does_not_contain_feedback_expectation());
+
+        // Finish the attempt.
+        $this->quba->finish_all_questions();
+
+        // Verify.
+        $this->check_current_state(question_state::$gradedpartial);
+        $this->check_current_mark(2);
+        $this->check_current_output(
+                $this->get_contains_select_expectation('sub0', $choices, $orderforchoice[13], false),
+                $this->get_contains_select_expectation('sub1', $choices, $orderforchoice[16], false),
+                $this->get_contains_select_expectation('sub2', $choices, null, false),
+                $this->get_contains_select_expectation('sub3', $choices, null, false),
+                $this->get_contains_partcorrect_expectation());
+    }
+
+    public function test_interactive_correct_no_submit() {
+
+        // Create a randomsamatching question.
+        $m = test_question_maker::make_question('randomsamatch');
+        $m->hints = array(
+            new question_hint_with_parts(11, 'This is the first hint.', FORMAT_HTML, false, false),
+            new question_hint_with_parts(12, 'This is the second hint.', FORMAT_HTML, true, true),
+        );
+        $m->shufflestems = false;
+        $this->start_attempt_at_question($m, 'interactive', 4);
+
+        $choiceorder = $m->get_choice_order();
+        $orderforchoice = array_combine(array_values($choiceorder), array_keys($choiceorder));
+        $choices = array(0 => get_string('choose') . '...');
+        foreach ($choiceorder as $key => $choice) {
+            $choices[$key] = $m->choices[$choice];
+        }
+
+        // Check the initial state.
+        $this->check_current_state(question_state::$todo);
+        $this->check_current_mark(null);
+        $this->check_current_output(
+                $this->get_contains_select_expectation('sub0', $choices, null, true),
+                $this->get_contains_select_expectation('sub1', $choices, null, true),
+                $this->get_contains_select_expectation('sub2', $choices, null, true),
+                $this->get_contains_select_expectation('sub3', $choices, null, true),
+                $this->get_contains_submit_button_expectation(true),
+                $this->get_does_not_contain_feedback_expectation(),
+                $this->get_tries_remaining_expectation(3),
+                $this->get_no_hint_visible_expectation());
+
+        // Save the right answer.
+        $this->process_submission(array('sub0' => $orderforchoice[13],
+                'sub1' => $orderforchoice[16], 'sub2' => $orderforchoice[16],
+                'sub3' => $orderforchoice[13]));
+
+        // Finish the attempt without clicking check.
+        $this->quba->finish_all_questions();
+
+        // Verify.
+        $this->check_current_state(question_state::$gradedright);
+        $this->check_current_mark(4);
+        $this->check_current_output(
+                $this->get_contains_select_expectation('sub0', $choices, $orderforchoice[13], false),
+                $this->get_contains_select_expectation('sub1', $choices, $orderforchoice[16], false),
+                $this->get_contains_select_expectation('sub2', $choices, $orderforchoice[16], false),
+                $this->get_contains_select_expectation('sub3', $choices, $orderforchoice[13], false),
+                $this->get_contains_submit_button_expectation(false),
+                $this->get_contains_correct_expectation(),
+                $this->get_no_hint_visible_expectation());
+    }
+
+    public function test_interactive_partial_no_submit() {
+
+        // Create a randomsamatching question.
+        $m = test_question_maker::make_question('randomsamatch');
+        $m->hints = array(
+            new question_hint_with_parts(11, 'This is the first hint.', FORMAT_HTML, false, false),
+            new question_hint_with_parts(12, 'This is the second hint.', FORMAT_HTML, true, true),
+        );
+        $m->shufflestems = false;
+        $this->start_attempt_at_question($m, 'interactive', 4);
+
+        $choiceorder = $m->get_choice_order();
+        $orderforchoice = array_combine(array_values($choiceorder), array_keys($choiceorder));
+        $choices = array(0 => get_string('choose') . '...');
+        foreach ($choiceorder as $key => $choice) {
+            $choices[$key] = $m->choices[$choice];
+        }
+
+        // Check the initial state.
+        $this->check_current_state(question_state::$todo);
+        $this->check_current_mark(null);
+        $this->check_current_output(
+                $this->get_contains_select_expectation('sub0', $choices, null, true),
+                $this->get_contains_select_expectation('sub1', $choices, null, true),
+                $this->get_contains_select_expectation('sub2', $choices, null, true),
+                $this->get_contains_select_expectation('sub3', $choices, null, true),
+                $this->get_contains_submit_button_expectation(true),
+                $this->get_does_not_contain_feedback_expectation(),
+                $this->get_tries_remaining_expectation(3),
+                $this->get_no_hint_visible_expectation());
+
+        // Save the right answer.
+        $this->process_submission(array('sub0' => $orderforchoice[13],
+                'sub1' => $orderforchoice[16], 'sub2' => $orderforchoice[13],
+                'sub3' => '0'));
+
+        // Finish the attempt without clicking check.
+        $this->quba->finish_all_questions();
+
+        // Verify.
+        $this->check_current_state(question_state::$gradedpartial);
+        $this->check_current_mark(2);
+        $this->check_current_output(
+                $this->get_contains_select_expectation('sub0', $choices, $orderforchoice[13], false),
+                $this->get_contains_select_expectation('sub1', $choices, $orderforchoice[16], false),
+                $this->get_contains_select_expectation('sub2', $choices, $orderforchoice[13], false),
+                $this->get_contains_select_expectation('sub3', $choices, null, false),
+                $this->get_contains_submit_button_expectation(false),
+                $this->get_contains_partcorrect_expectation(),
+                $this->get_no_hint_visible_expectation());
+    }
+
+    public function test_interactive_with_invalid() {
+
+        // Create a randomsamatching question.
+        $m = test_question_maker::make_question('randomsamatch');
+        $m->hints = array(
+            new question_hint_with_parts(11, 'This is the first hint.', FORMAT_HTML, false, false),
+            new question_hint_with_parts(12, 'This is the second hint.', FORMAT_HTML, true, true),
+        );
+        $m->shufflestems = false;
+        $this->start_attempt_at_question($m, 'interactive', 4);
+
+        $choiceorder = $m->get_choice_order();
+        $orderforchoice = array_combine(array_values($choiceorder), array_keys($choiceorder));
+        $choices = array(0 => get_string('choose') . '...');
+        foreach ($choiceorder as $key => $choice) {
+            $choices[$key] = $m->choices[$choice];
+        }
+
+        // Check the initial state.
+        $this->check_current_state(question_state::$todo);
+        $this->check_current_mark(null);
+        $this->check_current_output(
+                $this->get_contains_select_expectation('sub0', $choices, null, true),
+                $this->get_contains_select_expectation('sub1', $choices, null, true),
+                $this->get_contains_select_expectation('sub2', $choices, null, true),
+                $this->get_contains_select_expectation('sub3', $choices, null, true),
+                $this->get_contains_submit_button_expectation(true),
+                $this->get_does_not_contain_feedback_expectation(),
+                $this->get_tries_remaining_expectation(3),
+                $this->get_no_hint_visible_expectation());
+
+        // Try to submit an invalid answer.
+        $this->process_submission(array('sub0' => '0',
+                'sub1' => '0', 'sub2' => '0',
+                'sub3' => '0', '-submit' => '1'));
+
+        // Verify.
+        $this->check_current_state(question_state::$invalid);
+        $this->check_current_mark(null);
+        $this->check_current_output(
+                $this->get_contains_select_expectation('sub0', $choices, null, true),
+                $this->get_contains_select_expectation('sub1', $choices, null, true),
+                $this->get_contains_select_expectation('sub2', $choices, null, true),
+                $this->get_contains_select_expectation('sub3', $choices, null, true),
+                $this->get_contains_submit_button_expectation(true),
+                $this->get_does_not_contain_feedback_expectation(),
+                $this->get_invalid_answer_expectation(),
+                $this->get_no_hint_visible_expectation());
+
+        // Now submit the right answer.
+        $this->process_submission(array('sub0' => $orderforchoice[13],
+                'sub1' => $orderforchoice[16], 'sub2' => $orderforchoice[16],
+                'sub3' => $orderforchoice[13], '-submit' => '1'));
+
+        // Verify.
+        $this->check_current_state(question_state::$gradedright);
+        $this->check_current_mark(4);
+        $this->check_current_output(
+                $this->get_contains_select_expectation('sub0', $choices, $orderforchoice[13], false),
+                $this->get_contains_select_expectation('sub1', $choices, $orderforchoice[16], false),
+                $this->get_contains_select_expectation('sub2', $choices, $orderforchoice[16], false),
+                $this->get_contains_select_expectation('sub3', $choices, $orderforchoice[13], false),
+                $this->get_contains_submit_button_expectation(false),
+                $this->get_contains_correct_expectation(),
+                $this->get_no_hint_visible_expectation());
+    }
+
+    public function test_randomsamatch_clear_wrong() {
+
+        // Create a randomsamatching question.
+        $m = test_question_maker::make_question('randomsamatch');
+        $m->hints = array(
+            new question_hint_with_parts(11, 'This is the first hint.', FORMAT_HTML, false, true),
+            new question_hint_with_parts(12, 'This is the second hint.', FORMAT_HTML, true, true),
+        );
+        $m->shufflestems = false;
+        $this->start_attempt_at_question($m, 'interactive', 4);
+
+        $choiceorder = $m->get_choice_order();
+        $orderforchoice = array_combine(array_values($choiceorder), array_keys($choiceorder));
+        $choices = array(0 => get_string('choose') . '...');
+        foreach ($choiceorder as $key => $choice) {
+            $choices[$key] = $m->choices[$choice];
+        }
+
+        // Check the initial state.
+        $this->check_current_state(question_state::$todo);
+        $this->check_current_mark(null);
+        $this->check_current_output(
+                $this->get_contains_select_expectation('sub0', $choices, null, true),
+                $this->get_contains_select_expectation('sub1', $choices, null, true),
+                $this->get_contains_select_expectation('sub2', $choices, null, true),
+                $this->get_contains_select_expectation('sub3', $choices, null, true),
+                $this->get_contains_submit_button_expectation(true),
+                $this->get_does_not_contain_feedback_expectation(),
+                $this->get_tries_remaining_expectation(3),
+                $this->get_no_hint_visible_expectation());
+
+        // Submit a completely wrong response.
+        $this->process_submission(array('sub0' => $orderforchoice[16],
+                'sub1' => $orderforchoice[13], 'sub2' => $orderforchoice[13],
+                'sub3' => $orderforchoice[16], '-submit' => 1));
+
+        // Verify.
+        $this->check_current_state(question_state::$todo);
+        $this->check_current_mark(null);
+        $this->check_current_output(
+                $this->get_contains_select_expectation('sub0', $choices, $orderforchoice[16], false),
+                $this->get_contains_select_expectation('sub1', $choices, $orderforchoice[13], false),
+                $this->get_contains_select_expectation('sub2', $choices, $orderforchoice[13], false),
+                $this->get_contains_select_expectation('sub3', $choices, $orderforchoice[16], false),
+                $this->get_contains_hidden_expectation(
+                        $this->quba->get_field_prefix($this->slot) . 'sub0', '0'),
+                $this->get_contains_hidden_expectation(
+                        $this->quba->get_field_prefix($this->slot) . 'sub1', '0'),
+                $this->get_contains_hidden_expectation(
+                        $this->quba->get_field_prefix($this->slot) . 'sub2', '0'),
+                $this->get_contains_hidden_expectation(
+                        $this->quba->get_field_prefix($this->slot) . 'sub3', '0'),
+                $this->get_contains_submit_button_expectation(false),
+                $this->get_contains_hint_expectation('This is the first hint.'));
+
+        // Try again.
+        $this->process_submission(array('sub0' => 0,
+                'sub1' => 0, 'sub2' => 0,
+                'sub3' => 0, '-tryagain' => 1));
+
+        // Verify.
+        $this->check_current_state(question_state::$todo);
+        $this->check_current_mark(null);
+        $this->check_current_output(
+                $this->get_contains_select_expectation('sub0', $choices, null, true),
+                $this->get_contains_select_expectation('sub1', $choices, null, true),
+                $this->get_contains_select_expectation('sub2', $choices, null, true),
+                $this->get_contains_select_expectation('sub3', $choices, null, true),
+                $this->get_contains_submit_button_expectation(true),
+                $this->get_does_not_contain_feedback_expectation(),
+                $this->get_tries_remaining_expectation(2),
+                $this->get_no_hint_visible_expectation());
+
+        // Submit a partially wrong response.
+        $this->process_submission(array('sub0' => $orderforchoice[16],
+                'sub1' => $orderforchoice[13], 'sub2' => $orderforchoice[16],
+                'sub3' => $orderforchoice[13], '-submit' => 1));
+
+        // Verify.
+        $this->check_current_state(question_state::$todo);
+        $this->check_current_mark(null);
+        $this->check_current_output(
+                $this->get_contains_select_expectation('sub0', $choices, $orderforchoice[16], false),
+                $this->get_contains_select_expectation('sub1', $choices, $orderforchoice[13], false),
+                $this->get_contains_select_expectation('sub2', $choices, $orderforchoice[16], false),
+                $this->get_contains_select_expectation('sub3', $choices, $orderforchoice[13], false),
+                $this->get_contains_hidden_expectation(
+                        $this->quba->get_field_prefix($this->slot) . 'sub0', '0'),
+                $this->get_contains_hidden_expectation(
+                        $this->quba->get_field_prefix($this->slot) . 'sub1', '0'),
+                $this->get_contains_hidden_expectation(
+                        $this->quba->get_field_prefix($this->slot) . 'sub2', $orderforchoice[16]),
+                $this->get_contains_hidden_expectation(
+                        $this->quba->get_field_prefix($this->slot) . 'sub3', $orderforchoice[13]),
+                $this->get_contains_submit_button_expectation(false),
+                $this->get_contains_hint_expectation('This is the second hint.'));
+
+        // Try again.
+        $this->process_submission(array('sub0' => 0,
+                'sub1' => 0, 'sub2' => $orderforchoice[16],
+                'sub3' => $orderforchoice[13], '-tryagain' => 1));
+
+        // Verify.
+        $this->check_current_state(question_state::$todo);
+        $this->check_current_mark(null);
+        $this->check_current_output(
+                $this->get_contains_select_expectation('sub0', $choices, null, true),
+                $this->get_contains_select_expectation('sub1', $choices, null, true),
+                $this->get_contains_select_expectation('sub2', $choices, $orderforchoice[16], true),
+                $this->get_contains_select_expectation('sub3', $choices, $orderforchoice[13], true),
+                $this->get_contains_submit_button_expectation(true),
+                $this->get_does_not_contain_feedback_expectation(),
+                $this->get_tries_remaining_expectation(1),
+                $this->get_no_hint_visible_expectation());
+    }
+}
index 32b8726..232acb1 100644 (file)
 /**
  * Version information for the randomsamatch question type.
  *
- * @package    qtype
- * @subpackage randomsamatch
+ * @package    qtype_randomsamatch
  * @copyright  1999 onwards Martin Dougiamas  {@link http://moodle.com}
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
 
 defined('MOODLE_INTERNAL') || die();
 
-$plugin->version  = 2013110500;
+$plugin->version  = 2013110510;
 $plugin->requires = 2013110500;
+
 $plugin->component = 'qtype_randomsamatch';
+
+$plugin->dependencies = array(
+    'qtype_match' => 2013110500,
+    'qtype_shortanswer' => 2013110500,
+);
+
+$plugin->maturity  = MATURITY_STABLE;