Merge branch 'MDL-70063-master-1' of git://github.com/mihailges/moodle
authorSara Arjona <sara@moodle.com>
Wed, 11 Nov 2020 15:31:21 +0000 (16:31 +0100)
committerSara Arjona <sara@moodle.com>
Wed, 11 Nov 2020 15:32:08 +0000 (16:32 +0100)
43 files changed:
auth/db/auth.php
auth/db/tests/db_test.php
blocks/classes/external.php
blocks/tag_youtube/upgrade.txt
lib/amd/build/icon_system_fontawesome.min.js
lib/amd/build/icon_system_fontawesome.min.js.map
lib/amd/build/user_date.min.js
lib/amd/build/user_date.min.js.map
lib/amd/src/icon_system_fontawesome.js
lib/amd/src/user_date.js
mod/quiz/classes/external.php
mod/quiz/mod_form.php
mod/quiz/tests/behat/editing_add_from_question_bank.feature
mod/quiz/tests/external_test.php
mod/quiz/upgrade.txt
question/classes/bank/view.php
question/type/calculated/tests/question_test.php
question/type/ddimageortext/amd/build/question.min.js
question/type/ddimageortext/amd/build/question.min.js.map
question/type/ddimageortext/amd/src/question.js
question/type/essay/question.php
question/type/essay/tests/question_test.php
question/type/gapselect/questionbase.php
question/type/gapselect/tests/question_test.php
question/type/match/question.php
question/type/match/tests/question_test.php
question/type/multianswer/question.php
question/type/multianswer/tests/question_test.php
question/type/multichoice/question.php
question/type/multichoice/tests/question_multi_test.php
question/type/numerical/question.php
question/type/numerical/questiontype.php
question/type/numerical/tests/question_test.php
question/type/questionbase.php
question/type/shortanswer/question.php
question/type/shortanswer/tests/question_test.php
question/type/truefalse/question.php
question/type/truefalse/tests/question_test.php
tag/classes/tag.php
tag/tests/taglib_test.php
theme/boost/scss/moodle/modules.scss
theme/boost/style/moodle.css
theme/classic/style/moodle.css

index 5c0f74f..9d3cc18 100644 (file)
@@ -460,7 +460,7 @@ class auth_plugin_db extends auth_plugin_base {
                     continue;
                 }
                 try {
-                    $id = user_create_user($user, false); // It is truly a new user.
+                    $id = user_create_user($user, false, false); // It is truly a new user.
                     $trace->output(get_string('auth_dbinsertuser', 'auth_db', array('name'=>$user->username, 'id'=>$id)), 1);
                 } catch (moodle_exception $e) {
                     $trace->output(get_string('auth_dbinsertusererror', 'auth_db', $user->username), 1);
@@ -479,6 +479,8 @@ class auth_plugin_db extends auth_plugin_base {
 
                 // Make sure user context is present.
                 context_user::instance($id);
+
+                \core\event\user_created::create_from_userid($id)->trigger();
             }
             unset($add_users);
         }
index b17a7c0..1de432c 100644 (file)
@@ -119,6 +119,7 @@ class auth_db_testcase extends advanced_testcase {
         $table->add_field('email', XMLDB_TYPE_CHAR, '255', null, null, null);
         $table->add_field('firstname', XMLDB_TYPE_CHAR, '255', null, null, null);
         $table->add_field('lastname', XMLDB_TYPE_CHAR, '255', null, null, null);
+        $table->add_field('animal', XMLDB_TYPE_CHAR, '255', null, null, null);
         $table->add_key('primary', XMLDB_KEY_PRIMARY, array('id'));
         if ($dbman->table_exists($table)) {
             $dbman->drop_table($table);
@@ -137,6 +138,15 @@ class auth_db_testcase extends advanced_testcase {
         set_config('field_updateremote_email', '0', 'auth_db');
         set_config('field_lock_email', 'unlocked', 'auth_db');
 
+        // Create a user profile field and add mapping to it.
+        $DB->insert_record('user_info_field', ['shortname' => 'pet', 'name' => 'Pet', 'required' => 0,
+            'visible' => 1, 'locked' => 0, 'categoryid' => 1, 'datatype' => 'text']);
+
+        set_config('field_map_profile_field_pet', 'animal', 'auth_db');
+        set_config('field_updatelocal_profile_field_pet', 'oncreate', 'auth_db');
+        set_config('field_updateremote_profile_field_pet', '0', 'auth_db');
+        set_config('field_lock_profile_field_pet', 'unlocked', 'auth_db');
+
         // Init the rest of settings.
         set_config('passtype', 'plaintext', 'auth_db');
         set_config('changepasswordurl', '', 'auth_db');
@@ -156,6 +166,7 @@ class auth_db_testcase extends advanced_testcase {
 
     public function test_plugin() {
         global $DB, $CFG;
+        require_once($CFG->dirroot . '/user/profile/lib.php');
 
         $this->resetAfterTest(true);
 
@@ -193,7 +204,7 @@ class auth_db_testcase extends advanced_testcase {
 
         // Test bulk user account creation.
 
-        $user2 = (object)array('name'=>'u2', 'pass'=>'heslo', 'email'=>'u2@example.com');
+        $user2 = (object)['name' => 'u2', 'pass' => 'heslo', 'email' => 'u2@example.com', 'animal' => 'cat'];
         $user2->id = $DB->insert_record('auth_db_users', $user2);
 
         $user3 = (object)array('name'=>'admin', 'pass'=>'heslo', 'email'=>'admin@example.com'); // Should be skipped.
@@ -202,13 +213,24 @@ class auth_db_testcase extends advanced_testcase {
         $this->assertCount(2, $DB->get_records('user'));
 
         $trace = new null_progress_trace();
+
+        // Sync users and make sure that two events user_created werer triggered.
+        $sink = $this->redirectEvents();
         $auth->sync_users($trace, false);
+        $events = $sink->get_events();
+        $sink->close();
+        $this->assertCount(2, $events);
+        $this->assertTrue($events[0] instanceof  \core\event\user_created);
+        $this->assertTrue($events[1] instanceof  \core\event\user_created);
 
+        // Assert the two users were created.
         $this->assertEquals(4, $DB->count_records('user'));
         $u1 = $DB->get_record('user', array('username'=>$user1->name, 'auth'=>'db'));
         $this->assertSame($user1->email, $u1->email);
+        $this->assertEmpty(profile_user_record($u1->id)->pet);
         $u2 = $DB->get_record('user', array('username'=>$user2->name, 'auth'=>'db'));
         $this->assertSame($user2->email, $u2->email);
+        $this->assertSame($user2->animal, profile_user_record($u2->id)->pet);
         $admin = $DB->get_record('user', array('username'=>'admin', 'auth'=>'manual'));
         $this->assertNotEmpty($admin);
 
@@ -217,12 +239,14 @@ class auth_db_testcase extends advanced_testcase {
 
         $user2b = clone($user2);
         $user2b->email = 'u2b@example.com';
+        $user2b->animal = 'dog';
         $DB->update_record('auth_db_users', $user2b);
 
         $auth->sync_users($trace, false);
         $this->assertEquals(4, $DB->count_records('user'));
         $u2 = $DB->get_record('user', array('username'=>$user2->name));
         $this->assertSame($user2->email, $u2->email);
+        $this->assertSame($user2->animal, profile_user_record($u2->id)->pet);
 
         $auth->sync_users($trace, true);
         $this->assertEquals(4, $DB->count_records('user'));
@@ -231,6 +255,8 @@ class auth_db_testcase extends advanced_testcase {
 
         set_config('field_updatelocal_email', 'onlogin', 'auth_db');
         $auth->config->field_updatelocal_email = 'onlogin';
+        set_config('field_updatelocal_profile_field_pet', 'onlogin', 'auth_db');
+        $auth->config->field_updatelocal_profile_field_pet = 'onlogin';
 
         $auth->sync_users($trace, false);
         $this->assertEquals(4, $DB->count_records('user'));
@@ -241,6 +267,7 @@ class auth_db_testcase extends advanced_testcase {
         $this->assertEquals(4, $DB->count_records('user'));
         $u2 = $DB->get_record('user', array('username'=>$user2->name));
         $this->assertSame($user2b->email, $u2->email);
+        $this->assertSame($user2b->animal, profile_user_record($u2->id)->pet);
 
 
         // Test sync deletes and suspends.
index cc08180..41cddfa 100644 (file)
@@ -92,6 +92,9 @@ class core_block_external extends external_api {
     private static function get_all_current_page_blocks($includeinvisible = false, $returncontents = false) {
         global $PAGE, $OUTPUT;
 
+        // Set page URL to a fake URL to avoid errors.
+        $PAGE->set_url(new \moodle_url('/webservice/core_block_external/'));
+
         // Load the block instances for all the regions.
         $PAGE->blocks->load_blocks($includeinvisible);
         $PAGE->blocks->create_all_block_instances();
index 9824a12..b8afc46 100644 (file)
@@ -1,6 +1,6 @@
 This files describes API changes in the block tag_youtube code.
 
-=== 3.11 ===
+=== 3.10.1 ===
 
 * The config category now stores the category ID, instead of a string representation of the category name.
   In YouTube Data API v3, the API call to fetch the videos related to a certain category expects the category ID to be
index 47c4244..10ffe0b 100644 (file)
Binary files a/lib/amd/build/icon_system_fontawesome.min.js and b/lib/amd/build/icon_system_fontawesome.min.js differ
index 1bb4e1d..374c798 100644 (file)
Binary files a/lib/amd/build/icon_system_fontawesome.min.js.map and b/lib/amd/build/icon_system_fontawesome.min.js.map differ
index c221d1a..884e803 100644 (file)
Binary files a/lib/amd/build/user_date.min.js and b/lib/amd/build/user_date.min.js differ
index 87973d0..0b1e565 100644 (file)
Binary files a/lib/amd/build/user_date.min.js.map and b/lib/amd/build/user_date.min.js.map differ
index 25c749c..39c4711 100644 (file)
@@ -42,11 +42,13 @@ define(['core/icon_system', 'jquery', 'core/ajax', 'core/mustache', 'core/locals
      * @return {Promise}
      */
     IconSystemFontawesome.prototype.init = function() {
+        var currTheme = M.cfg.theme;
+
         if (staticMap) {
             return $.when(this);
         }
 
-        var map = LocalStorage.get('core/iconmap-fontawesome');
+        var map = LocalStorage.get('core_iconsystem/theme/' + currTheme + '/core/iconmap-fontawesome');
         if (map) {
             map = JSON.parse(map);
         }
@@ -70,7 +72,7 @@ define(['core/icon_system', 'jquery', 'core/ajax', 'core/mustache', 'core/locals
             $.each(map, function(index, value) {
                 staticMap[value.component + '/' + value.pix] = value.to;
             });
-            LocalStorage.set('core/iconmap-fontawesome', JSON.stringify(staticMap));
+            LocalStorage.set('core_iconsystem/theme/' + currTheme + '/core/iconmap-fontawesome', JSON.stringify(staticMap));
             return this;
         }.bind(this));
     };
index 95efe3d..15c8be7 100644 (file)
@@ -112,7 +112,7 @@ define(['jquery', 'core/ajax', 'core/sessionstorage', 'core/config'],
             return {
                 timestamp: data.timestamp,
                 format: data.format,
-                type: data.type || '',
+                type: data.type || null,
                 fixday: fixDay,
                 fixhour: fixHour
             };
index 2c207bd..3b6a6f2 100644 (file)
@@ -902,6 +902,7 @@ class mod_quiz_external extends external_api {
                     It will be returned only if the user is allowed to see it.', VALUE_OPTIONAL),
                 'maxmark' => new external_value(PARAM_FLOAT, 'the maximum mark possible for this question attempt.
                     It will be returned only if the user is allowed to see it.', VALUE_OPTIONAL),
+                'settings' => new external_value(PARAM_RAW, 'Question settings (JSON encoded).', VALUE_OPTIONAL),
             ),
             'The question data. Some fields may not be returned depending on the quiz display settings.'
         );
@@ -927,6 +928,7 @@ class mod_quiz_external extends external_api {
         foreach ($attemptobj->get_slots($page) as $slot) {
             $qtype = $attemptobj->get_question_type_name($slot);
             $qattempt = $attemptobj->get_question_attempt($slot);
+            $questiondef = $qattempt->get_question(true);
 
             // Get response files (for questions like essay that allows attachments).
             $responsefileareas = [];
@@ -948,6 +950,9 @@ class mod_quiz_external extends external_api {
                 }
             }
 
+            // Check display settings for question.
+            $settings = $questiondef->get_question_definition_for_external_rendering($qattempt, $displayoptions);
+
             $question = array(
                 'slot' => $slot,
                 'type' => $qtype,
@@ -957,7 +962,8 @@ class mod_quiz_external extends external_api {
                 'responsefileareas' => $responsefileareas,
                 'sequencecheck' => $qattempt->get_sequence_check_count(),
                 'lastactiontime' => $qattempt->get_last_step()->get_timecreated(),
-                'hasautosavedstep' => $qattempt->has_autosaved_step()
+                'hasautosavedstep' => $qattempt->has_autosaved_step(),
+                'settings' => !empty($settings) ? json_encode($settings) : null,
             );
 
             if ($attemptobj->is_real_question($slot)) {
index 9f3c90e..86bf35b 100644 (file)
@@ -378,11 +378,13 @@ class mod_quiz_mod_form extends moodleform_mod {
             list($identifier, $component) = $string;
 
             $label = get_string($identifier, $component);
+            $group[] = $mform->createElement('html', html_writer::start_div('review_option_item'));
+            $el = $mform->createElement('checkbox', $field . $whenname, '', $label);
             if ($withhelp) {
-                $label .= ' ' . $OUTPUT->help_icon($identifier, $component);
+                $el->_helpbutton = $OUTPUT->render(new help_icon($identifier, $component));
             }
-
-            $group[] = $mform->createElement('checkbox', $field . $whenname, '', $label);
+            $group[] = $el;
+            $group[] = $mform->createElement('html', html_writer::end_div());
         }
         $mform->addGroup($group, $whenname . 'optionsgrp',
                 get_string('review' . $whenname, 'quiz'), null, false);
index b8c8e68..3ff5acd 100644 (file)
@@ -79,6 +79,9 @@ Feature: Adding questions to a quiz from the question bank
     Then I should see "question 21 name" in the "categoryquestions" "table"
     And I should see "question 22 name" in the "categoryquestions" "table"
     And I should not see "question 01 name" in the "categoryquestions" "table"
+    And I click on "Show all 22" "link" in the ".pagingbottom" "css_element"
+    And I should see "question 01 name" in the "categoryquestions" "table"
+    And I should see "question 22 name" in the "categoryquestions" "table"
 
   Scenario: Questions are added in the right place with multiple sections
     Given the following "questions" exist:
index 0ea6a4e..9d97fce 100644 (file)
@@ -1034,6 +1034,11 @@ class mod_quiz_external_testcase extends externallib_advanced_testcase {
         $this->assertEquals(false, $result['questions'][0]['hasautosavedstep']);
         $this->assertEquals(false, $result['questions'][1]['hasautosavedstep']);
 
+        // Check question options.
+        $this->assertNotEmpty(5, $result['questions'][0]['settings']);
+        // Check at least some settings returned.
+        $this->assertCount(4, (array) json_decode($result['questions'][0]['settings']));
+
         // Submit a response for the first question.
         $tosubmit = array(1 => array('answer' => '3.14'));
         $attemptobj->process_submitted_actions(time(), false, $tosubmit);
index d3f6c09..3af165d 100644 (file)
@@ -1,5 +1,11 @@
 This files describes API changes in the quiz code.
 
+=== 3.10.1 ===
+
+* External functions mod_quiz_external::get_attempt_data, mod_quiz_external::get_attempt_summary
+  and mod_quiz_external::get_attempt_review now return a new additional optional field:
+   - settings: Containing the question definition settings for displaying the question in an external system.
+
 === 3.10 ===
 
 * External functions mod_quiz_external::get_attempt_data, mod_quiz_external::get_attempt_summary
index c8ec225..de74be8 100644 (file)
@@ -860,7 +860,7 @@ class view {
         if ($totalnumber > DEFAULT_QUESTIONS_PER_PAGE) {
             if ($perpage == DEFAULT_QUESTIONS_PER_PAGE) {
                 $url = new \moodle_url('edit.php', array_merge($pageurl->params(),
-                        array('qperpage' => MAXIMUM_QUESTIONS_PER_PAGE)));
+                        array('qpage' => 0, 'qperpage' => MAXIMUM_QUESTIONS_PER_PAGE)));
                 if ($totalnumber > MAXIMUM_QUESTIONS_PER_PAGE) {
                     $showall = '<a href="'.$url.'">'.get_string('showperpage', 'moodle', MAXIMUM_QUESTIONS_PER_PAGE).'</a>';
                 } else {
index 924f62e..28a9d15 100644 (file)
@@ -178,4 +178,23 @@ class qtype_calculated_question_test extends advanced_testcase {
         $this->assertEquals('category' . $question->category,
                 $question->get_variants_selection_seed());
     }
+
+    /**
+     * test_get_question_definition_for_external_rendering
+     */
+    public function test_get_question_definition_for_external_rendering() {
+        $this->resetAfterTest();
+
+        $question = test_question_maker::make_question('calculated');
+        $question->start_attempt(new question_attempt_step(), 1);
+        $qa = test_question_maker::get_a_qa($question);
+        $displayoptions = new question_display_options();
+
+        $options = $question->get_question_definition_for_external_rendering($qa, $displayoptions);
+        $this->assertNotEmpty($options);
+        $this->assertEquals(0, $options['unitgradingtype']);
+        $this->assertEquals(0, $options['unitpenalty']);
+        $this->assertEquals(qtype_numerical::UNITNONE, $options['unitdisplay']);
+        $this->assertEmpty($options['unitsleft']);
+    }
 }
index d315b9d..7bebe76 100644 (file)
Binary files a/question/type/ddimageortext/amd/build/question.min.js and b/question/type/ddimageortext/amd/build/question.min.js differ
index 28b05d3..801f740 100644 (file)
Binary files a/question/type/ddimageortext/amd/build/question.min.js.map and b/question/type/ddimageortext/amd/build/question.min.js.map differ
index 8d031bf..c62e611 100644 (file)
@@ -364,10 +364,12 @@ define(['jquery', 'core/dragdrop', 'core/key_codes'], function($, dragDrop, keys
      * @param {jQuery} drag the item being moved.
      */
     DragDropOntoImageQuestion.prototype.dragMove = function(pageX, pageY, drag) {
-        var thisQ = this;
+        var thisQ = this,
+            highlighted = false;
         this.getRoot().find('.dropzone.group' + this.getGroup(drag)).each(function(i, dropNode) {
             var drop = $(dropNode);
-            if (thisQ.isPointInDrop(pageX, pageY, drop)) {
+            if (thisQ.isPointInDrop(pageX, pageY, drop) && !highlighted) {
+                highlighted = true;
                 drop.addClass('valid-drag-over-drop');
             } else {
                 drop.removeClass('valid-drag-over-drop');
@@ -375,7 +377,8 @@ define(['jquery', 'core/dragdrop', 'core/key_codes'], function($, dragDrop, keys
         });
         this.getRoot().find('.draghome.placed.group' + this.getGroup(drag)).not('.beingdragged').each(function(i, dropNode) {
             var drop = $(dropNode);
-            if (thisQ.isPointInDrop(pageX, pageY, drop)) {
+            if (thisQ.isPointInDrop(pageX, pageY, drop) && !highlighted && !thisQ.isDragSameAsDrop(drag, drop)) {
+                highlighted = true;
                 drop.addClass('valid-drag-over-drop');
             } else {
                 drop.removeClass('valid-drag-over-drop');
@@ -394,6 +397,8 @@ define(['jquery', 'core/dragdrop', 'core/key_codes'], function($, dragDrop, keys
         var thisQ = this,
             root = this.getRoot(),
             placed = false;
+
+        // Looking for drag that was dropped on a dropzone.
         root.find('.dropzone.group' + this.getGroup(drag)).each(function(i, dropNode) {
             var drop = $(dropNode);
             if (!thisQ.isPointInDrop(pageX, pageY, drop)) {
@@ -408,21 +413,24 @@ define(['jquery', 'core/dragdrop', 'core/key_codes'], function($, dragDrop, keys
             return false; // Stop the each() here.
         });
 
-        root.find('.draghome.placed.group' + this.getGroup(drag)).not('.beingdragged').each(function(i, placedNode) {
-            var placedDrag = $(placedNode);
-            if (!thisQ.isPointInDrop(pageX, pageY, placedDrag)) {
-                // Not this placed drag.
-                return true;
-            }
+        if (!placed) {
+            // Looking for drag that was dropped on a placed drag.
+            root.find('.draghome.placed.group' + this.getGroup(drag)).not('.beingdragged').each(function(i, placedNode) {
+                var placedDrag = $(placedNode);
+                if (!thisQ.isPointInDrop(pageX, pageY, placedDrag) || thisQ.isDragSameAsDrop(drag, placedDrag)) {
+                    // Not this placed drag.
+                    return true;
+                }
 
-            // Now put this drag into the drop.
-            placedDrag.removeClass('valid-drag-over-drop');
-            var currentPlace = thisQ.getClassnameNumericSuffix(placedDrag, 'inplace');
-            var drop = thisQ.getDrop(drag, currentPlace);
-            thisQ.sendDragToDrop(drag, drop);
-            placed = true;
-            return false; // Stop the each() here.
-        });
+                // Now put this drag into the drop.
+                placedDrag.removeClass('valid-drag-over-drop');
+                var currentPlace = thisQ.getClassnameNumericSuffix(placedDrag, 'inplace');
+                var drop = thisQ.getDrop(drag, currentPlace);
+                thisQ.sendDragToDrop(drag, drop);
+                placed = true;
+                return false; // Stop the each() here.
+            });
+        }
 
         if (!placed) {
             this.sendDragHome(drag);
@@ -917,6 +925,17 @@ define(['jquery', 'core/dragdrop', 'core/key_codes'], function($, dragDrop, keys
         return zIndex;
     };
 
+    /**
+     * Check that the drag is drop to it's clone.
+     *
+     * @param {jQuery} drag The drag.
+     * @param {jQuery} drop The drop.
+     * @returns {boolean}
+     */
+    DragDropOntoImageQuestion.prototype.isDragSameAsDrop = function(drag, drop) {
+        return this.getChoice(drag) === this.getChoice(drop) && this.getGroup(drag) === this.getGroup(drop);
+    };
+
     /**
      * Singleton object that handles all the DragDropOntoImageQuestions
      * on the page, and deals with event dispatching.
@@ -996,6 +1015,8 @@ define(['jquery', 'core/dragdrop', 'core/key_codes'], function($, dragDrop, keys
          * @param {jQuery} element Element to bind the event
          */
         addEventHandlersToDrag: function(element) {
+            // Unbind all the mousedown and touchstart events to prevent double binding.
+            element.unbind('mousedown touchstart');
             element.on('mousedown touchstart', questionManager.handleDragStart);
         },
 
index b74cca2..8f1f03a 100644 (file)
@@ -185,4 +185,31 @@ class qtype_essay_question extends question_with_responses {
                     $filearea, $args, $forcedownload);
         }
     }
+
+    /**
+     * Return the question settings that define this question as structured data.
+     *
+     * @param question_attempt $qa the current attempt for which we are exporting the settings.
+     * @param question_display_options $options the question display options which say which aspects of the question
+     * should be visible.
+     * @return mixed structure representing the question settings. In web services, this will be JSON-encoded.
+     */
+    public function get_question_definition_for_external_rendering(question_attempt $qa, question_display_options $options) {
+        // This is a partial implementation, returning only the most relevant question settings for now,
+        // ideally, we should return as much as settings as possible (depending on the state and display options).
+
+        $settings = [
+            'responseformat' => $this->responseformat,
+            'responserequired' => $this->responserequired,
+            'responsefieldlines' => $this->responsefieldlines,
+            'attachments' => $this->attachments,
+            'attachmentsrequired' => $this->attachmentsrequired,
+            'maxbytes' => $this->maxbytes,
+            'filetypeslist' => $this->filetypeslist,
+            'responsetemplate' => $this->responsetemplate,
+            'responsetemplateformat' => $this->responsetemplateformat,
+        ];
+
+        return $settings;
+    }
 }
index 2ac7e16..9467ecb 100644 (file)
@@ -239,4 +239,27 @@ class qtype_essay_question_test extends advanced_testcase {
 
     }
 
+    /**
+     * test_get_question_definition_for_external_rendering
+     */
+    public function test_get_question_definition_for_external_rendering() {
+        $this->resetAfterTest();
+
+        $essay = test_question_maker::make_an_essay_question();
+        $essay->start_attempt(new question_attempt_step(), 1);
+        $qa = test_question_maker::get_a_qa($essay);
+        $displayoptions = new question_display_options();
+
+        $options = $essay->get_question_definition_for_external_rendering($qa, $displayoptions);
+        $this->assertNotEmpty($options);
+        $this->assertEquals('editor', $options['responseformat']);
+        $this->assertEquals(1, $options['responserequired']);
+        $this->assertEquals(15, $options['responsefieldlines']);
+        $this->assertEquals(0, $options['attachments']);
+        $this->assertEquals(0, $options['attachmentsrequired']);
+        $this->assertNull($options['maxbytes']);
+        $this->assertNull($options['filetypeslist']);
+        $this->assertEquals('', $options['responsetemplate']);
+        $this->assertEquals(FORMAT_MOODLE, $options['responsetemplateformat']);
+    }
 }
index a64e67c..0a93eb4 100644 (file)
@@ -331,4 +331,21 @@ abstract class qtype_gapselect_question_base extends question_graded_automatical
                     $args, $forcedownload);
         }
     }
+
+    /**
+     * Return the question settings that define this question as structured data.
+     *
+     * @param question_attempt $qa the current attempt for which we are exporting the settings.
+     * @param question_display_options $options the question display options which say which aspects of the question
+     * should be visible.
+     * @return mixed structure representing the question settings. In web services, this will be JSON-encoded.
+     */
+    public function get_question_definition_for_external_rendering(question_attempt $qa, question_display_options $options) {
+        // This is a partial implementation, returning only the most relevant question settings for now,
+        // ideally, we should return as much as settings as possible (depending on the state and display options).
+
+        return [
+            'shufflechoices' => $this->shufflechoices,
+        ];
+    }
 }
index 4bfdfd6..4e4663a 100644 (file)
@@ -246,4 +246,17 @@ class qtype_gapselect_question_test extends basic_testcase {
                     3 => new question_classified_response(2, 'assiduous', 0),
                 ), $gapselect->classify_response(array('p1' => '0', 'p2' => '1', 'p3' => '2')));
     }
+
+    /**
+     * test_get_question_definition_for_external_rendering
+     */
+    public function test_get_question_definition_for_external_rendering() {
+        $question = test_question_maker::make_question('gapselect', 'maths');
+        $question->start_attempt(new question_attempt_step(), 1);
+        $qa = test_question_maker::get_a_qa($question);
+        $displayoptions = new question_display_options();
+
+        $options = $question->get_question_definition_for_external_rendering($qa, $displayoptions);
+        $this->assertEquals(1, $options['shufflechoices']);
+    }
 }
index 6aba21d..15996ce 100644 (file)
@@ -354,4 +354,21 @@ class qtype_match_question extends question_graded_automatically_with_countback
                     $args, $forcedownload);
         }
     }
+
+    /**
+     * Return the question settings that define this question as structured data.
+     *
+     * @param question_attempt $qa the current attempt for which we are exporting the settings.
+     * @param question_display_options $options the question display options which say which aspects of the question
+     * should be visible.
+     * @return mixed structure representing the question settings. In web services, this will be JSON-encoded.
+     */
+    public function get_question_definition_for_external_rendering(question_attempt $qa, question_display_options $options) {
+        // This is a partial implementation, returning only the most relevant question settings for now,
+        // ideally, we should return as much as settings as possible (depending on the state and display options).
+
+        return [
+            'shufflestems' => $this->shufflestems,
+        ];
+    }
 }
index 67ab401..d516fa3 100644 (file)
@@ -233,4 +233,16 @@ class qtype_match_question_test extends advanced_testcase {
         $this->assertEquals(array(4, 4), $m->get_num_parts_right($postdata));
     }
 
+    /**
+     * test_get_question_definition_for_external_rendering
+     */
+    public function test_get_question_definition_for_external_rendering() {
+        $question = test_question_maker::make_question('match');
+        $question->start_attempt(new question_attempt_step(), 1);
+        $qa = test_question_maker::get_a_qa($question);
+        $displayoptions = new question_display_options();
+
+        $options = $question->get_question_definition_for_external_rendering($qa, $displayoptions);
+        $this->assertEquals(1, $options['shufflestems']);
+    }
 }
index 9b6707d..de55224 100644 (file)
@@ -355,4 +355,19 @@ class qtype_multianswer_question extends question_graded_automatically_with_coun
                     $args, $forcedownload);
         }
     }
+
+    /**
+     * Return the question settings that define this question as structured data.
+     *
+     * @param question_attempt $qa the current attempt for which we are exporting the settings.
+     * @param question_display_options $options the question display options which say which aspects of the question
+     * should be visible.
+     * @return mixed structure representing the question settings. In web services, this will be JSON-encoded.
+     */
+    public function get_question_definition_for_external_rendering(question_attempt $qa, question_display_options $options) {
+        // Empty implementation for now in order to avoid debugging in core questions (generated in the parent class),
+        // ideally, we should return as much as settings as possible (depending on the state and display options).
+
+        return null;
+    }
 }
index 9390ad2..487cb57 100644 (file)
@@ -238,4 +238,19 @@ class qtype_multianswer_question_test extends advanced_testcase {
         $finalgrade = $question->compute_final_grade($responses, 1);
         $this->assertEquals(1 / 3 * (1 - 3 * 0.2) + 2 / 3 * (1 - 2 * 0.2), $finalgrade);
     }
+
+    /**
+     * test_get_question_definition_for_external_rendering
+     */
+    public function test_get_question_definition_for_external_rendering() {
+        $this->resetAfterTest();
+
+        $question = test_question_maker::make_question('multianswer');
+        $question->start_attempt(new question_attempt_step(), 1);
+        $qa = test_question_maker::get_a_qa($question);
+        $displayoptions = new question_display_options();
+
+        $options = $question->get_question_definition_for_external_rendering($qa, $displayoptions);
+        $this->assertNull($options);
+    }
 }
index 624c909..ec9916c 100644 (file)
@@ -147,6 +147,26 @@ abstract class qtype_multichoice_base extends question_graded_automatically {
                     $args, $forcedownload);
         }
     }
+
+    /**
+     * Return the question settings that define this question as structured data.
+     *
+     * @param question_attempt $qa the current attempt for which we are exporting the settings.
+     * @param question_display_options $options the question display options which say which aspects of the question
+     * should be visible.
+     * @return mixed structure representing the question settings. In web services, this will be JSON-encoded.
+     */
+    public function get_question_definition_for_external_rendering(question_attempt $qa, question_display_options $options) {
+        // This is a partial implementation, returning only the most relevant question settings for now,
+        // ideally, we should return as much as settings as possible (depending on the state and display options).
+
+        return [
+            'shuffleanswers' => $this->shuffleanswers,
+            'answernumbering' => $this->answernumbering,
+            'showstandardinstruction' => $this->showstandardinstruction,
+            'layout' => $this->layout,
+        ];
+    }
 }
 
 
index d062024..f5e532c 100644 (file)
@@ -175,4 +175,19 @@ class qtype_multichoice_multi_question_test extends advanced_testcase {
         }
     }
 
+    /**
+     * test_get_question_definition_for_external_rendering
+     */
+    public function test_get_question_definition_for_external_rendering() {
+        $question = test_question_maker::make_a_multichoice_multi_question();
+        $question->start_attempt(new question_attempt_step(), 1);
+        $qa = test_question_maker::get_a_qa($question);
+        $displayoptions = new question_display_options();
+
+        $options = $question->get_question_definition_for_external_rendering($qa, $displayoptions);
+        $this->assertEquals(1, $options['shuffleanswers']);
+        $this->assertEquals('abc', $options['answernumbering']);
+        $this->assertEquals(0, $options['showstandardinstruction']);
+        $this->assertEquals(1, $options['shuffleanswers']);
+    }
 }
index 79733d4..7137ed8 100644 (file)
@@ -44,7 +44,8 @@ class qtype_numerical_question extends question_graded_automatically {
     public $unitgradingtype;
     /** @var number the penalty for a missing or unrecognised unit. */
     public $unitpenalty;
-
+    /** @var boolean whether the units come before or after the number */
+    public $unitsleft;
     /** @var qtype_numerical_answer_processor */
     public $ap;
 
@@ -318,6 +319,26 @@ class qtype_numerical_question extends question_graded_automatically {
                     $args, $forcedownload);
         }
     }
+
+    /**
+     * Return the question settings that define this question as structured data.
+     *
+     * @param question_attempt $qa the current attempt for which we are exporting the settings.
+     * @param question_display_options $options the question display options which say which aspects of the question
+     * should be visible.
+     * @return mixed structure representing the question settings. In web services, this will be JSON-encoded.
+     */
+    public function get_question_definition_for_external_rendering(question_attempt $qa, question_display_options $options) {
+        // This is a partial implementation, returning only the most relevant question settings for now,
+        // ideally, we should return as much as settings as possible (depending on the state and display options).
+
+        return [
+            'unitgradingtype' => $this->unitgradingtype,
+            'unitpenalty' => $this->unitpenalty,
+            'unitdisplay' => $this->unitdisplay,
+            'unitsleft' => $this->unitsleft,
+        ];
+    }
 }
 
 
index 6e2a89a..25754a9 100644 (file)
@@ -359,6 +359,7 @@ class qtype_numerical extends question_type {
         $question->unitdisplay = $questiondata->options->showunits;
         $question->unitgradingtype = $questiondata->options->unitgradingtype;
         $question->unitpenalty = $questiondata->options->unitpenalty;
+        $question->unitsleft = $questiondata->options->unitsleft;
         $question->ap = $this->make_answer_processor($questiondata->options->units,
                 $questiondata->options->unitsleft);
     }
index 73a6960..33eaaf5 100644 (file)
@@ -306,4 +306,23 @@ class qtype_numerical_question_test extends advanced_testcase {
                 new question_classified_response(null, '$abc', 0.0)),
                 $num->classify_response(array('answer' => '$abc')));
     }
+
+    /**
+     * test_get_question_definition_for_external_rendering
+     */
+    public function test_get_question_definition_for_external_rendering() {
+        $this->resetAfterTest();
+
+        $question = test_question_maker::make_question('numerical', 'unit');
+        $question->start_attempt(new question_attempt_step(), 1);
+        $qa = test_question_maker::get_a_qa($question);
+        $displayoptions = new question_display_options();
+
+        $options = $question->get_question_definition_for_external_rendering($qa, $displayoptions);
+        $this->assertNotEmpty($options);
+        $this->assertEquals(1, $options['unitgradingtype']);
+        $this->assertEquals(0.5, $options['unitpenalty']);
+        $this->assertEquals(qtype_numerical::UNITSELECT, $options['unitdisplay']);
+        $this->assertEmpty($options['unitsleft']);
+    }
 }
index 4e2ea87..d045f86 100644 (file)
@@ -427,6 +427,31 @@ abstract class question_definition {
             return false;
         }
     }
+
+    /**
+     * Return the question settings that define this question as structured data.
+     *
+     * This is used by external systems such as the Moodle mobile app, which want to display the question themselves,
+     * rather than using the renderer provided.
+     *
+     * This method should only return the data that the student is allowed to see or know, given the current state of
+     * the question. For example, do not include the 'General feedback' until the student has completed the question,
+     * and even then, only include it if the question_display_options say it should be visible.
+     *
+     * But, within those rules, it is recommended that you return all the settings for the question,
+     * to give maximum flexibility to the external system providing its own rendering of the question.
+     *
+     * @param question_attempt $qa the current attempt for which we are exporting the settings.
+     * @param question_display_options $options the question display options which say which aspects of the question
+     * should be visible.
+     * @return mixed structure representing the question settings. In web services, this will be JSON-encoded.
+     */
+    public function get_question_definition_for_external_rendering(question_attempt $qa, question_display_options $options) {
+
+        debugging('This question does not implement the get_question_definition_for_external_rendering() method yet.',
+            DEBUG_DEVELOPER);
+        return null;
+    }
 }
 
 
index a9de53b..b8923aa 100644 (file)
@@ -184,4 +184,17 @@ class qtype_shortanswer_question extends question_graded_by_strategy
                     $args, $forcedownload);
         }
     }
+
+    /**
+     * Return the question settings that define this question as structured data.
+     *
+     * @param question_attempt $qa the current attempt for which we are exporting the settings.
+     * @param question_display_options $options the question display options which say which aspects of the question
+     * should be visible.
+     * @return mixed structure representing the question settings. In web services, this will be JSON-encoded.
+     */
+    public function get_question_definition_for_external_rendering(question_attempt $qa, question_display_options $options) {
+        // No need to return anything, external clients do not need additional information for rendering this question type.
+        return null;
+    }
 }
index c8c05cf..89f4d38 100644 (file)
@@ -226,4 +226,19 @@ class qtype_shortanswer_question_test extends advanced_testcase {
                 question_classified_response::no_response()),
                 $sa->classify_response(array('answer' => '')));
     }
+
+    /**
+     * test_get_question_definition_for_external_rendering
+     */
+    public function test_get_question_definition_for_external_rendering() {
+        $this->resetAfterTest();
+
+        $question = test_question_maker::make_question('shortanswer');
+        $question->start_attempt(new question_attempt_step(), 1);
+        $qa = test_question_maker::get_a_qa($question);
+        $displayoptions = new question_display_options();
+
+        $options = $question->get_question_definition_for_external_rendering($qa, $displayoptions);
+        $this->assertNull($options);
+    }
 }
index 05d1d69..320781b 100644 (file)
@@ -123,4 +123,17 @@ class qtype_truefalse_question extends question_graded_automatically {
                     $args, $forcedownload);
         }
     }
+
+    /**
+     * Return the question settings that define this question as structured data.
+     *
+     * @param question_attempt $qa the current attempt for which we are exporting the settings.
+     * @param question_display_options $options the question display options which say which aspects of the question
+     * should be visible.
+     * @return mixed structure representing the question settings. In web services, this will be JSON-encoded.
+     */
+    public function get_question_definition_for_external_rendering(question_attempt $qa, question_display_options $options) {
+        // No need to return anything, external clients do not need additional information for rendering this question type.
+        return null;
+    }
 }
index 4d68b4b..fb53054 100644 (file)
@@ -107,4 +107,19 @@ class qtype_truefalse_question_test extends advanced_testcase {
                 $tf->id => question_classified_response::no_response()),
                 $tf->classify_response(array()));
     }
+
+    /**
+     * test_get_question_definition_for_external_rendering
+     */
+    public function test_get_question_definition_for_external_rendering() {
+        $this->resetAfterTest();
+
+        $question = test_question_maker::make_question('truefalse', 'true');
+        $question->start_attempt(new question_attempt_step(), 1);
+        $qa = test_question_maker::get_a_qa($question);
+        $displayoptions = new question_display_options();
+
+        $options = $question->get_question_definition_for_external_rendering($qa, $displayoptions);
+        $this->assertNull($options);
+    }
 }
index ba04be8..a84efca 100644 (file)
@@ -81,7 +81,7 @@ class core_tag_tag {
      */
     protected function __construct($record) {
         if (empty($record->id)) {
-            throw new coding_exeption("Record must contain at least field 'id'");
+            throw new coding_exception("Record must contain at least field 'id'");
         }
         $this->record = $record;
     }
@@ -753,7 +753,7 @@ class core_tag_tag {
     public static function set_item_tags($component, $itemtype, $itemid, context $context, $tagnames, $tiuserid = 0) {
         if ($itemtype === 'tag') {
             if ($tiuserid) {
-                throw new coding_exeption('Related tags can not have tag instance userid');
+                throw new coding_exception('Related tags can not have tag instance userid');
             }
             debugging('You can not use set_item_tags() for tagging a tag, please use set_related_tags()', DEBUG_DEVELOPER);
             static::get($itemid, '*', MUST_EXIST)->set_related_tags($tagnames);
index 28c62f9..bad9b14 100644 (file)
@@ -275,6 +275,17 @@ class core_tag_taglib_testcase extends advanced_testcase {
         $this->assertEquals(0, $instancecount);
     }
 
+    /**
+     * Test that setting a list of tags for "tag" item type throws exception if userid specified
+     */
+    public function test_set_item_tags_with_invalid_userid(): void {
+        $user = $this->getDataGenerator()->create_user();
+
+        $this->expectException(coding_exception::class);
+        $this->expectExceptionMessage('Related tags can not have tag instance userid');
+        core_tag_tag::set_item_tags('core', 'tag', 1, context_system::instance(), ['all', 'night', 'long'], $user->id);
+    }
+
     /**
      * Prepares environment for testing tag correlations
      * @return core_tag_tag[] list of used tags
index 5218e74..4e11c5c 100644 (file)
@@ -1159,11 +1159,16 @@ div#dock {
     clear: left;
 }
 #page-mod-quiz-mod #id_reviewoptionshdr .form-check {
-    width: 90%;
+    width: auto;
     height: 22px;
     justify-content: flex-start;
 }
 
+#page-mod-quiz-mod #id_reviewoptionshdr .review_option_item {
+    width: 90%;
+    height: 22px;
+}
+
 // Question navigation block.
 .path-mod-quiz #mod_quiz_navblock {
     .qnbutton {
index 59f212a..87f7ce7 100644 (file)
@@ -17478,10 +17478,14 @@ div#dock {
   clear: left; }
 
 #page-mod-quiz-mod #id_reviewoptionshdr .form-check {
-  width: 90%;
+  width: auto;
   height: 22px;
   justify-content: flex-start; }
 
+#page-mod-quiz-mod #id_reviewoptionshdr .review_option_item {
+  width: 90%;
+  height: 22px; }
+
 .path-mod-quiz #mod_quiz_navblock .qnbutton {
   text-decoration: none;
   font-size: 14px;
index cf7cc90..d8d736c 100644 (file)
@@ -17705,10 +17705,14 @@ div#dock {
   clear: left; }
 
 #page-mod-quiz-mod #id_reviewoptionshdr .form-check {
-  width: 90%;
+  width: auto;
   height: 22px;
   justify-content: flex-start; }
 
+#page-mod-quiz-mod #id_reviewoptionshdr .review_option_item {
+  width: 90%;
+  height: 22px; }
+
 .path-mod-quiz #mod_quiz_navblock .qnbutton {
   text-decoration: none;
   font-size: 14px;