Merge branch 'MDL-52832-master' of https://github.com/lucaboesch/moodle
authorDavid Monllao <davidm@moodle.com>
Tue, 6 Feb 2018 11:18:27 +0000 (12:18 +0100)
committerDavid Monllao <davidm@moodle.com>
Tue, 6 Feb 2018 11:18:27 +0000 (12:18 +0100)
60 files changed:
admin/tool/cohortroles/classes/output/cohort_role_assignments_table.php
admin/tool/uploadcourse/classes/course.php
admin/tool/uploadcourse/lang/en/tool_uploadcourse.php
admin/tool/uploadcourse/tests/course_test.php
backup/moodle2/restore_stepslib.php
backup/util/dbops/restore_dbops.class.php
backup/util/helper/restore_questions_parser_processor.class.php
composer.json
composer.lock
course/edit_form.php
course/externallib.php
course/tests/externallib_test.php
course/upgrade.txt
lang/en/admin.php
lang/en/moodle.php
lang/en/question.php
lang/en/role.php
lib/classes/output/icon_system_fontawesome.php
lib/db/access.php
lib/db/services.php
lib/db/upgrade.php
lib/deprecatedlib.php
lib/questionlib.php
lib/tests/questionlib_test.php
mod/assign/locallib.php
mod/assign/submission/onlinetext/locallib.php
mod/quiz/addrandom.php
mod/quiz/addrandomform.php
mod/quiz/overrides.php
mod/quiz/tests/behat/quiz_user_override.feature [new file with mode: 0644]
question/amd/build/edit_tags.min.js [new file with mode: 0644]
question/amd/build/repository.min.js [new file with mode: 0644]
question/amd/build/selectors.min.js [new file with mode: 0644]
question/amd/src/edit_tags.js [new file with mode: 0644]
question/amd/src/repository.js [new file with mode: 0644]
question/amd/src/selectors.js [new file with mode: 0644]
question/category_class.php
question/category_form.php
question/classes/bank/search/category_condition.php
question/classes/bank/tags_action_column.php [new file with mode: 0644]
question/classes/bank/view.php
question/classes/external.php
question/editlib.php
question/export_form.php
question/format.php
question/lib.php [new file with mode: 0644]
question/tests/behat/copy_questions.feature
question/tests/behat/question_categories.feature
question/tests/generator/lib.php
question/tests/generator_test.php
question/type/edit_question_form.php
question/type/random/edit_random_form.php
question/type/random/lang/en/qtype_random.php
question/type/random/questiontype.php
question/type/tags_form.php [new file with mode: 0644]
theme/boost/scss/moodle/forms.scss
theme/boost/scss/moodle/tool_usertours.scss
user/message.html
user/profile/field/datetime/field.class.php
version.php

index 08c8c3f..29405c2 100644 (file)
@@ -98,7 +98,7 @@ class cohort_role_assignments_table extends table_sql {
         );
         $context = context_helper::instance_by_id($data->cohortcontextid);
 
-        $exporter = new \tool_lp\external\cohort_summary_exporter($record, array('context' => $context));
+        $exporter = new \core_cohort\external\cohort_summary_exporter($record, array('context' => $context));
         $cohort = $exporter->export($OUTPUT);
 
         $html = $OUTPUT->render_from_template('tool_cohortroles/cohort-in-list', $cohort);
index da1592e..ed3adea 100644 (file)
@@ -592,6 +592,23 @@ class tool_uploadcourse_course {
             $coursedata['enddate'] = strtotime($coursedata['enddate']);
         }
 
+        // If lang is specified, check the user is allowed to set that field.
+        if (!empty($coursedata['lang'])) {
+            if ($exists) {
+                $courseid = $DB->get_field('course', 'id', ['shortname' => $this->shortname]);
+                if (!has_capability('moodle/course:setforcedlanguage', context_course::instance($courseid))) {
+                    $this->error('cannotforcelang', new lang_string('cannotforcelang', 'tool_uploadcourse'));
+                    return false;
+                }
+            } else {
+                $catcontext = context_coursecat::instance($coursedata['category']);
+                if (!guess_if_creator_will_have_course_capability('moodle/course:setforcedlanguage', $catcontext)) {
+                    $this->error('cannotforcelang', new lang_string('cannotforcelang', 'tool_uploadcourse'));
+                    return false;
+                }
+            }
+        }
+
         // Ultimate check mode vs. existence.
         switch ($mode) {
             case tool_uploadcourse_processor::MODE_CREATE_NEW:
index 96b847c..d82440c 100644 (file)
@@ -30,6 +30,7 @@ $string['allowresets'] = 'Allow resets';
 $string['allowresets_help'] = 'Whether the reset field is accepted or not.';
 $string['cachedef_helper'] = 'Helper caching';
 $string['cannotdeletecoursenotexist'] = 'Cannot delete a course that does not exist';
+$string['cannotforcelang'] = 'No permission to force language for this course';
 $string['cannotgenerateshortnameupdatemode'] = 'Cannot generate a shortname when updates are allowed';
 $string['cannotreadbackupfile'] = 'Cannot read the backup file';
 $string['cannotrenamecoursenotexist'] = 'Cannot rename a course that does not exist';
index 3356163..f52ec31 100644 (file)
@@ -266,6 +266,8 @@ class tool_uploadcourse_course_testcase extends advanced_testcase {
         global $DB;
         $this->resetAfterTest(true);
 
+        $this->setAdminUser(); // To avoid warnings related to 'moodle/course:setforcedlanguage' capability check.
+
         // Create.
         $mode = tool_uploadcourse_processor::MODE_CREATE_NEW;
         $updatemode = tool_uploadcourse_processor::UPDATE_NOTHING;
index f17a6ed..6e050ab 100644 (file)
@@ -1827,6 +1827,7 @@ class restore_course_structure_step extends restore_structure_step {
         // When restoring to a new course we can set all the things except for the ID number.
         $canchangeidnumber = $isnewcourse || has_capability('moodle/course:changeidnumber', $context, $userid);
         $canchangesummary = $isnewcourse || has_capability('moodle/course:changesummary', $context, $userid);
+        $canforcelanguage = has_capability('moodle/course:setforcedlanguage', $context, $userid);
 
         $data = (object)$data;
         $data->id = $this->get_courseid();
@@ -1851,6 +1852,11 @@ class restore_course_structure_step extends restore_structure_step {
             unset($data->summaryformat);
         }
 
+        // Unset lang if user can't change it.
+        if (!$canforcelanguage) {
+            unset($data->lang);
+        }
+
         // Only allow the idnumber to be set if the user has permission and the idnumber is not already in use by
         // another course on this site.
         if (!empty($data->idnumber) && $canchangeidnumber && $this->task->is_samesite()
@@ -1889,7 +1895,7 @@ class restore_course_structure_step extends restore_structure_step {
             $data->completionnotify = 0;
         }
         $languages = get_string_manager()->get_list_of_translations(); // Get languages for quick search
-        if (!array_key_exists($data->lang, $languages)) {
+        if (isset($data->lang) && !array_key_exists($data->lang, $languages)) {
             $data->lang = '';
         }
 
@@ -4394,6 +4400,21 @@ class restore_create_categories_and_questions extends restore_structure_step {
         }
         $data->contextid = $mapping->parentitemid;
 
+        // Before 3.5, question categories could be created at top level.
+        // From 3.5 onwards, all question categories should be a child of a special category called the "top" category.
+        $backuprelease = floatval($this->get_task()->get_info()->backup_release);
+        preg_match('/(\d{8})/', $this->get_task()->get_info()->moodle_release, $matches);
+        $backupbuild = (int)$matches[1];
+        $before35 = false;
+        if ($backuprelease < 3.5 || $backupbuild < 20180205) {
+            $before35 = true;
+        }
+        if (empty($mapping->info->parent) &&
+                ($before35 || $mapping->info->contextlevel == CONTEXT_MODULE)) {
+            $top = question_get_top_category($data->contextid, true);
+            $data->parent = $top->id;
+        }
+
         // Before 3.1, the 'stamp' field could be erroneously duplicated.
         // From 3.1 onwards, there's a unique index of (contextid, stamp).
         // If we encounter a duplicate in an old restore file, just generate a new stamp.
@@ -4554,7 +4575,6 @@ class restore_create_categories_and_questions extends restore_structure_step {
                      'backupid' => $this->get_restoreid(),
                      'itemname' => 'question_category_created'));
         foreach ($qcats as $qcat) {
-            $newparent = 0;
             $dbcat = $DB->get_record('question_categories', array('id' => $qcat->newitemid));
             // Get new parent (mapped or created, so we look in quesiton_category mappings)
             if ($newparent = $DB->get_field('backup_ids_temp', 'newitemid', array(
@@ -4570,8 +4590,11 @@ class restore_create_categories_and_questions extends restore_structure_step {
                 }
             }
             // Here with $newparent empty, problem with contexts or remapping, set it to top cat
-            if (!$newparent) {
-                $DB->set_field('question_categories', 'parent', 0, array('id' => $dbcat->id));
+            if (!$newparent && $dbcat->parent) {
+                $topcat = question_get_top_category($dbcat->contextid, true);
+                if ($dbcat->parent != $topcat->id) {
+                    $DB->set_field('question_categories', 'parent', $topcat->id, array('id' => $dbcat->id));
+                }
             }
         }
 
@@ -4580,7 +4603,6 @@ class restore_create_categories_and_questions extends restore_structure_step {
                   'backupid' => $this->get_restoreid(),
                   'itemname' => 'question_created'));
         foreach ($qs as $q) {
-            $newparent = 0;
             $dbq = $DB->get_record('question', array('id' => $q->newitemid));
             // Get new parent (mapped or created, so we look in question mappings)
             if ($newparent = $DB->get_field('backup_ids_temp', 'newitemid', array(
@@ -4609,18 +4631,38 @@ class restore_move_module_questions_categories extends restore_execution_step {
     protected function define_execution() {
         global $DB;
 
+        $backuprelease = floatval($this->task->get_info()->backup_release);
+        preg_match('/(\d{8})/', $this->task->get_info()->moodle_release, $matches);
+        $backupbuild = (int)$matches[1];
+        $before35 = false;
+        if ($backuprelease < 3.5 || $backupbuild < 20180205) {
+            $before35 = true;
+        }
+
         $contexts = restore_dbops::restore_get_question_banks($this->get_restoreid(), CONTEXT_MODULE);
         foreach ($contexts as $contextid => $contextlevel) {
             // Only if context mapping exists (i.e. the module has been restored)
             if ($newcontext = restore_dbops::get_backup_ids_record($this->get_restoreid(), 'context', $contextid)) {
                 // Update all the qcats having their parentitemid set to the original contextid
-                $modulecats = $DB->get_records_sql("SELECT itemid, newitemid
+                $modulecats = $DB->get_records_sql("SELECT itemid, newitemid, info
                                                       FROM {backup_ids_temp}
                                                      WHERE backupid = ?
                                                        AND itemname = 'question_category'
                                                        AND parentitemid = ?", array($this->get_restoreid(), $contextid));
                 foreach ($modulecats as $modulecat) {
-                    $DB->set_field('question_categories', 'contextid', $newcontext->newitemid, array('id' => $modulecat->newitemid));
+                    $cat = new stdClass();
+                    $cat->id = $modulecat->newitemid;
+                    $cat->contextid = $newcontext->newitemid;
+
+                    // Before 3.5, question categories could be created at top level.
+                    // From 3.5 onwards, all question categories should be a child of a special category called the "top" category.
+                    $info = backup_controller_dbops::decode_backup_temp_info($modulecat->info);
+                    if ($before35 && empty($info->parent)) {
+                        $top = question_get_top_category($newcontext->newitemid, true);
+                        $cat->parent = $top->id;
+                    }
+                    $DB->update_record('question_categories', $cat);
+
                     // And set new contextid also in question_category mapping (will be
                     // used by {@link restore_create_question_files} later
                     restore_dbops::set_backup_ids_record($this->get_restoreid(), 'question_category', $modulecat->itemid, $modulecat->newitemid, $newcontext->newitemid);
index a4c7075..ec45776 100644 (file)
@@ -558,9 +558,16 @@ abstract class restore_dbops {
      *
      * The function returns 2 arrays, one containing errors and another containing
      * warnings. Both empty if no errors/warnings are found.
+     *
+     * @param int $restoreid The restore ID
+     * @param int $courseid The ID of the course
+     * @param int $userid The id of the user doing the restore
+     * @param bool $samesite True if restore is to same site
+     * @param int $contextlevel (CONTEXT_SYSTEM, etc.)
+     * @return array A separate list of all error and warnings detected
      */
     public static function prechek_precheck_qbanks_by_level($restoreid, $courseid, $userid, $samesite, $contextlevel) {
-        global $CFG, $DB;
+        global $DB;
 
         // To return any errors and warnings found
         $errors   = array();
@@ -571,6 +578,17 @@ abstract class restore_dbops {
             CONTEXT_SYSTEM => CONTEXT_COURSE,
             CONTEXT_COURSECAT => CONTEXT_COURSE);
 
+        $rc = restore_controller_dbops::load_controller($restoreid);
+        $restoreinfo = $rc->get_info();
+        $rc->destroy(); // Always need to destroy.
+        $backuprelease = floatval($restoreinfo->backup_release);
+        preg_match('/(\d{8})/', $restoreinfo->moodle_release, $matches);
+        $backupbuild = (int)$matches[1];
+        $after35 = false;
+        if ($backuprelease >= 3.5 && $backupbuild > 20180205) {
+            $after35 = true;
+        }
+
         // For any contextlevel, follow this process logic:
         //
         // 0) Iterate over each context (qbank)
@@ -587,6 +605,7 @@ abstract class restore_dbops {
         //                 7a) There is fallback, move ALL the qcats to fallback, warn. End qcat loop
         //                 7b) No fallback, error. End qcat loop
         //         5b) Match, mark q to be mapped
+        // 8) Check if backup is from Moodle >= 3.5 and error if more than one top-level category in the context.
 
         // Get all the contexts (question banks) in restore for the given contextlevel
         $contexts = self::restore_get_question_banks($restoreid, $contextlevel);
@@ -596,6 +615,8 @@ abstract class restore_dbops {
             // Init some perms
             $canmanagecategory = false;
             $canadd            = false;
+            // Top-level category counter.
+            $topcats = 0;
             // get categories in context (bank)
             $categories = self::restore_get_question_categories($restoreid, $contextid);
             // cache permissions if $targetcontext is found
@@ -605,6 +626,10 @@ abstract class restore_dbops {
             }
             // 1) Iterate over each qcat in the context, matching by stamp for the found target context
             foreach ($categories as $category) {
+                if ($category->parent == 0) {
+                    $topcats++;
+                }
+
                 $matchcat = false;
                 if ($targetcontext) {
                     $matchcat = $DB->get_record('question_categories', array(
@@ -690,6 +715,12 @@ abstract class restore_dbops {
                     }
                 }
             }
+
+            // 8) Check if backup is made on Moodle >= 3.5 and there are more than one top-level category in the context.
+            if ($after35 && $topcats > 1) {
+                $errors[] = get_string('restoremultipletopcats', 'questions', $contextid);
+            }
+
         }
 
         return array($errors, $warnings);
index 4e94468..129b2f4 100644 (file)
@@ -26,7 +26,7 @@ require_once($CFG->dirroot.'/backup/util/xml/parser/processors/grouped_parser_pr
 
 /**
  * helper implementation of grouped_parser_processor that will
- * load all the categories and questions (header info only) from then questions.xml file
+ * load all the categories and questions (header info only) from the questions.xml file
  * to the backup_ids table storing the whole structure there for later processing.
  * Note: only "needed" categories are loaded (must have question_categoryref record in backup_ids)
  * Note: parentitemid will contain the category->contextid for categories
index 3dd8de4..70c46b7 100644 (file)
@@ -1,13 +1,13 @@
 {
     "name": "moodle/moodle",
-    "license": "GPL-3.0",
+    "license": "GPL-3.0-or-later",
     "description": "Moodle - the world's open source learning platform",
     "type": "project",
     "homepage": "https://moodle.org",
     "require-dev": {
         "phpunit/phpunit": "6.4.*",
         "phpunit/dbUnit": "3.0.*",
-        "moodlehq/behat-extension": "3.35.0",
+        "moodlehq/behat-extension": "3.35.1",
         "mikey179/vfsStream": "^1.6"
     }
 }
index f90ae23..b9fe34a 100644 (file)
@@ -4,7 +4,7 @@
         "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#composer-lock-the-lock-file",
         "This file is @generated automatically"
     ],
-    "content-hash": "7cd70172c941fb07f0a2d4173baef5f1",
+    "content-hash": "93454a669db9cfbc99f35f7bacd6ac82",
     "packages": [],
     "packages-dev": [
         {
         },
         {
             "name": "moodlehq/behat-extension",
-            "version": "v3.35.0",
+            "version": "v3.35.1",
             "source": {
                 "type": "git",
                 "url": "https://github.com/moodlehq/moodle-behat-extension.git",
-                "reference": "8d0c4248b1efe6bc141fc7dc17d16fed1df017a5"
+                "reference": "e6e92fd551185f73603bad5694e854f3f6906e0e"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/moodlehq/moodle-behat-extension/zipball/8d0c4248b1efe6bc141fc7dc17d16fed1df017a5",
-                "reference": "8d0c4248b1efe6bc141fc7dc17d16fed1df017a5",
+                "url": "https://api.github.com/repos/moodlehq/moodle-behat-extension/zipball/e6e92fd551185f73603bad5694e854f3f6906e0e",
+                "reference": "e6e92fd551185f73603bad5694e854f3f6906e0e",
                 "shasum": ""
             },
             "require": {
                 "Behat",
                 "moodle"
             ],
-            "time": "2017-09-29T18:10:58+00:00"
+            "time": "2018-01-24T14:09:40+00:00"
         },
         {
             "name": "myclabs/deep-copy",
         },
         {
             "name": "phpdocumentor/reflection-docblock",
-            "version": "4.1.1",
+            "version": "4.2.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/phpDocumentor/ReflectionDocBlock.git",
-                "reference": "2d3d238c433cf69caeb4842e97a3223a116f94b2"
+                "reference": "66465776cfc249844bde6d117abff1d22e06c2da"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/2d3d238c433cf69caeb4842e97a3223a116f94b2",
-                "reference": "2d3d238c433cf69caeb4842e97a3223a116f94b2",
+                "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/66465776cfc249844bde6d117abff1d22e06c2da",
+                "reference": "66465776cfc249844bde6d117abff1d22e06c2da",
                 "shasum": ""
             },
             "require": {
                 "php": "^7.0",
-                "phpdocumentor/reflection-common": "^1.0@dev",
+                "phpdocumentor/reflection-common": "^1.0.0",
                 "phpdocumentor/type-resolver": "^0.4.0",
                 "webmozart/assert": "^1.0"
             },
             "require-dev": {
-                "mockery/mockery": "^0.9.4",
-                "phpunit/phpunit": "^4.4"
+                "doctrine/instantiator": "~1.0.5",
+                "mockery/mockery": "^1.0",
+                "phpunit/phpunit": "^6.4"
             },
             "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "4.x-dev"
+                }
+            },
             "autoload": {
                 "psr-4": {
                     "phpDocumentor\\Reflection\\": [
                 }
             ],
             "description": "With this component, a library can provide support for annotations via DocBlocks or otherwise retrieve information that is embedded in a DocBlock.",
-            "time": "2017-08-30T18:51:59+00:00"
+            "time": "2017-11-27T17:38:31+00:00"
         },
         {
             "name": "phpdocumentor/type-resolver",
         },
         {
             "name": "phpunit/dbunit",
-            "version": "3.0.2",
+            "version": "3.0.3",
             "source": {
                 "type": "git",
                 "url": "https://github.com/sebastianbergmann/dbunit.git",
-                "reference": "403350339b6aca748ee0067d027d85621992e21f"
+                "reference": "0fa4329e490480ab957fe7b1185ea0996ca11f44"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/sebastianbergmann/dbunit/zipball/403350339b6aca748ee0067d027d85621992e21f",
-                "reference": "403350339b6aca748ee0067d027d85621992e21f",
+                "url": "https://api.github.com/repos/sebastianbergmann/dbunit/zipball/0fa4329e490480ab957fe7b1185ea0996ca11f44",
+                "reference": "0fa4329e490480ab957fe7b1185ea0996ca11f44",
                 "shasum": ""
             },
             "require": {
                 "testing",
                 "xunit"
             ],
-            "time": "2017-11-18T17:40:34+00:00"
+            "time": "2018-01-23T13:32:26+00:00"
         },
         {
             "name": "phpunit/php-code-coverage",
-            "version": "5.2.3",
+            "version": "5.3.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/sebastianbergmann/php-code-coverage.git",
-                "reference": "8e1d2397d8adf59a3f12b2878a3aaa66d1ab189d"
+                "reference": "661f34d0bd3f1a7225ef491a70a020ad23a057a1"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/8e1d2397d8adf59a3f12b2878a3aaa66d1ab189d",
-                "reference": "8e1d2397d8adf59a3f12b2878a3aaa66d1ab189d",
+                "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/661f34d0bd3f1a7225ef491a70a020ad23a057a1",
+                "reference": "661f34d0bd3f1a7225ef491a70a020ad23a057a1",
                 "shasum": ""
             },
             "require": {
                 "php": "^7.0",
                 "phpunit/php-file-iterator": "^1.4.2",
                 "phpunit/php-text-template": "^1.2.1",
-                "phpunit/php-token-stream": "^2.0",
+                "phpunit/php-token-stream": "^2.0.1",
                 "sebastian/code-unit-reverse-lookup": "^1.0.1",
                 "sebastian/environment": "^3.0",
                 "sebastian/version": "^2.0.1",
                 "theseer/tokenizer": "^1.1"
             },
             "require-dev": {
-                "ext-xdebug": "^2.5",
                 "phpunit/phpunit": "^6.0"
             },
             "suggest": {
             "type": "library",
             "extra": {
                 "branch-alias": {
-                    "dev-master": "5.2.x-dev"
+                    "dev-master": "5.3.x-dev"
                 }
             },
             "autoload": {
             "authors": [
                 {
                     "name": "Sebastian Bergmann",
-                    "email": "sb@sebastian-bergmann.de",
+                    "email": "sebastian@phpunit.de",
                     "role": "lead"
                 }
             ],
                 "testing",
                 "xunit"
             ],
-            "time": "2017-11-03T13:47:33+00:00"
+            "time": "2017-12-06T09:29:45+00:00"
         },
         {
             "name": "phpunit/php-file-iterator",
-            "version": "1.4.3",
+            "version": "1.4.5",
             "source": {
                 "type": "git",
                 "url": "https://github.com/sebastianbergmann/php-file-iterator.git",
-                "reference": "8ebba84e5bd74fc5fdeb916b38749016c7232f93"
+                "reference": "730b01bc3e867237eaac355e06a36b85dd93a8b4"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/8ebba84e5bd74fc5fdeb916b38749016c7232f93",
-                "reference": "8ebba84e5bd74fc5fdeb916b38749016c7232f93",
+                "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/730b01bc3e867237eaac355e06a36b85dd93a8b4",
+                "reference": "730b01bc3e867237eaac355e06a36b85dd93a8b4",
                 "shasum": ""
             },
             "require": {
                 "filesystem",
                 "iterator"
             ],
-            "time": "2017-11-24T15:00:59+00:00"
+            "time": "2017-11-27T13:52:08+00:00"
         },
         {
             "name": "phpunit/php-text-template",
         },
         {
             "name": "phpunit/php-token-stream",
-            "version": "2.0.1",
+            "version": "2.0.2",
             "source": {
                 "type": "git",
                 "url": "https://github.com/sebastianbergmann/php-token-stream.git",
-                "reference": "9a02332089ac48e704c70f6cefed30c224e3c0b0"
+                "reference": "791198a2c6254db10131eecfe8c06670700904db"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/sebastianbergmann/php-token-stream/zipball/9a02332089ac48e704c70f6cefed30c224e3c0b0",
-                "reference": "9a02332089ac48e704c70f6cefed30c224e3c0b0",
+                "url": "https://api.github.com/repos/sebastianbergmann/php-token-stream/zipball/791198a2c6254db10131eecfe8c06670700904db",
+                "reference": "791198a2c6254db10131eecfe8c06670700904db",
                 "shasum": ""
             },
             "require": {
             "keywords": [
                 "tokenizer"
             ],
-            "time": "2017-08-20T05:47:52+00:00"
+            "time": "2017-11-27T05:48:46+00:00"
         },
         {
             "name": "phpunit/phpunit",
         },
         {
             "name": "sebastian/comparator",
-            "version": "2.1.0",
+            "version": "2.1.2",
             "source": {
                 "type": "git",
                 "url": "https://github.com/sebastianbergmann/comparator.git",
-                "reference": "1174d9018191e93cb9d719edec01257fc05f8158"
+                "reference": "11c07feade1d65453e06df3b3b90171d6d982087"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/1174d9018191e93cb9d719edec01257fc05f8158",
-                "reference": "1174d9018191e93cb9d719edec01257fc05f8158",
+                "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/11c07feade1d65453e06df3b3b90171d6d982087",
+                "reference": "11c07feade1d65453e06df3b3b90171d6d982087",
                 "shasum": ""
             },
             "require": {
                 "compare",
                 "equality"
             ],
-            "time": "2017-11-03T07:16:52+00:00"
+            "time": "2018-01-12T06:34:42+00:00"
         },
         {
             "name": "sebastian/diff",
         },
         {
             "name": "symfony/browser-kit",
-            "version": "v3.3.13",
+            "version": "v3.4.3",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/browser-kit.git",
-                "reference": "03f957cd24bf939524f07b8b910c89cfcad722a8"
+                "reference": "490f27762705c8489bd042fe3e9377a191dba9b4"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/browser-kit/zipball/03f957cd24bf939524f07b8b910c89cfcad722a8",
-                "reference": "03f957cd24bf939524f07b8b910c89cfcad722a8",
+                "url": "https://api.github.com/repos/symfony/browser-kit/zipball/490f27762705c8489bd042fe3e9377a191dba9b4",
+                "reference": "490f27762705c8489bd042fe3e9377a191dba9b4",
                 "shasum": ""
             },
             "require": {
                 "php": "^5.5.9|>=7.0.8",
-                "symfony/dom-crawler": "~2.8|~3.0"
+                "symfony/dom-crawler": "~2.8|~3.0|~4.0"
             },
             "require-dev": {
-                "symfony/css-selector": "~2.8|~3.0",
-                "symfony/process": "~2.8|~3.0"
+                "symfony/css-selector": "~2.8|~3.0|~4.0",
+                "symfony/process": "~2.8|~3.0|~4.0"
             },
             "suggest": {
                 "symfony/process": ""
             "type": "library",
             "extra": {
                 "branch-alias": {
-                    "dev-master": "3.3-dev"
+                    "dev-master": "3.4-dev"
                 }
             },
             "autoload": {
             ],
             "description": "Symfony BrowserKit Component",
             "homepage": "https://symfony.com",
-            "time": "2017-11-07T14:12:55+00:00"
+            "time": "2018-01-03T07:37:34+00:00"
         },
         {
             "name": "symfony/class-loader",
-            "version": "v3.3.13",
+            "version": "v3.4.3",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/class-loader.git",
-                "reference": "df173ac2af96ce202bf8bb5a3fc0bec8a4fdd4d1"
+                "reference": "e63c12699822bb3b667e7216ba07fbcc3a3e203e"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/class-loader/zipball/df173ac2af96ce202bf8bb5a3fc0bec8a4fdd4d1",
-                "reference": "df173ac2af96ce202bf8bb5a3fc0bec8a4fdd4d1",
+                "url": "https://api.github.com/repos/symfony/class-loader/zipball/e63c12699822bb3b667e7216ba07fbcc3a3e203e",
+                "reference": "e63c12699822bb3b667e7216ba07fbcc3a3e203e",
                 "shasum": ""
             },
             "require": {
                 "php": "^5.5.9|>=7.0.8"
             },
             "require-dev": {
-                "symfony/finder": "~2.8|~3.0",
+                "symfony/finder": "~2.8|~3.0|~4.0",
                 "symfony/polyfill-apcu": "~1.1"
             },
             "suggest": {
             "type": "library",
             "extra": {
                 "branch-alias": {
-                    "dev-master": "3.3-dev"
+                    "dev-master": "3.4-dev"
                 }
             },
             "autoload": {
             ],
             "description": "Symfony ClassLoader Component",
             "homepage": "https://symfony.com",
-            "time": "2017-11-05T15:47:03+00:00"
+            "time": "2018-01-03T07:37:34+00:00"
         },
         {
             "name": "symfony/config",
-            "version": "v3.3.13",
+            "version": "v3.4.3",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/config.git",
-                "reference": "8d2649077dc54dfbaf521d31f217383d82303c5f"
+                "reference": "cfd5c972f7b4992a5df41673d25d980ab077aa5b"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/config/zipball/8d2649077dc54dfbaf521d31f217383d82303c5f",
-                "reference": "8d2649077dc54dfbaf521d31f217383d82303c5f",
+                "url": "https://api.github.com/repos/symfony/config/zipball/cfd5c972f7b4992a5df41673d25d980ab077aa5b",
+                "reference": "cfd5c972f7b4992a5df41673d25d980ab077aa5b",
                 "shasum": ""
             },
             "require": {
                 "php": "^5.5.9|>=7.0.8",
-                "symfony/filesystem": "~2.8|~3.0"
+                "symfony/filesystem": "~2.8|~3.0|~4.0"
             },
             "conflict": {
                 "symfony/dependency-injection": "<3.3",
                 "symfony/finder": "<3.3"
             },
             "require-dev": {
-                "symfony/dependency-injection": "~3.3",
-                "symfony/finder": "~3.3",
-                "symfony/yaml": "~3.0"
+                "symfony/dependency-injection": "~3.3|~4.0",
+                "symfony/finder": "~3.3|~4.0",
+                "symfony/yaml": "~3.0|~4.0"
             },
             "suggest": {
                 "symfony/yaml": "To use the yaml reference dumper"
             "type": "library",
             "extra": {
                 "branch-alias": {
-                    "dev-master": "3.3-dev"
+                    "dev-master": "3.4-dev"
                 }
             },
             "autoload": {
             ],
             "description": "Symfony Config Component",
             "homepage": "https://symfony.com",
-            "time": "2017-11-07T14:16:22+00:00"
+            "time": "2018-01-03T07:37:34+00:00"
         },
         {
             "name": "symfony/console",
-            "version": "v3.3.13",
+            "version": "v3.3.15",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/console.git",
-                "reference": "63cd7960a0a522c3537f6326706d7f3b8de65805"
+                "reference": "56f07f63c80baeb43ab259abc42c6cecf6461fae"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/console/zipball/63cd7960a0a522c3537f6326706d7f3b8de65805",
-                "reference": "63cd7960a0a522c3537f6326706d7f3b8de65805",
+                "url": "https://api.github.com/repos/symfony/console/zipball/56f07f63c80baeb43ab259abc42c6cecf6461fae",
+                "reference": "56f07f63c80baeb43ab259abc42c6cecf6461fae",
                 "shasum": ""
             },
             "require": {
             ],
             "description": "Symfony Console Component",
             "homepage": "https://symfony.com",
-            "time": "2017-11-16T15:24:32+00:00"
+            "time": "2018-01-03T07:37:11+00:00"
         },
         {
             "name": "symfony/css-selector",
-            "version": "v3.3.13",
+            "version": "v3.4.3",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/css-selector.git",
-                "reference": "66e6e046032ebdf1f562c26928549f613d428bd1"
+                "reference": "e66394bc7610e69279bfdb3ab11b4fe65403f556"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/css-selector/zipball/66e6e046032ebdf1f562c26928549f613d428bd1",
-                "reference": "66e6e046032ebdf1f562c26928549f613d428bd1",
+                "url": "https://api.github.com/repos/symfony/css-selector/zipball/e66394bc7610e69279bfdb3ab11b4fe65403f556",
+                "reference": "e66394bc7610e69279bfdb3ab11b4fe65403f556",
                 "shasum": ""
             },
             "require": {
             "type": "library",
             "extra": {
                 "branch-alias": {
-                    "dev-master": "3.3-dev"
+                    "dev-master": "3.4-dev"
                 }
             },
             "autoload": {
             ],
             "description": "Symfony CssSelector Component",
             "homepage": "https://symfony.com",
-            "time": "2017-11-05T15:47:03+00:00"
+            "time": "2018-01-03T07:37:34+00:00"
         },
         {
             "name": "symfony/debug",
-            "version": "v3.3.13",
+            "version": "v3.4.3",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/debug.git",
-                "reference": "74557880e2846b5c84029faa96b834da37e29810"
+                "reference": "603b95dda8b00020e4e6e60dc906e7b715b1c245"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/debug/zipball/74557880e2846b5c84029faa96b834da37e29810",
-                "reference": "74557880e2846b5c84029faa96b834da37e29810",
+                "url": "https://api.github.com/repos/symfony/debug/zipball/603b95dda8b00020e4e6e60dc906e7b715b1c245",
+                "reference": "603b95dda8b00020e4e6e60dc906e7b715b1c245",
                 "shasum": ""
             },
             "require": {
                 "symfony/http-kernel": ">=2.3,<2.3.24|~2.4.0|>=2.5,<2.5.9|>=2.6,<2.6.2"
             },
             "require-dev": {
-                "symfony/http-kernel": "~2.8|~3.0"
+                "symfony/http-kernel": "~2.8|~3.0|~4.0"
             },
             "type": "library",
             "extra": {
                 "branch-alias": {
-                    "dev-master": "3.3-dev"
+                    "dev-master": "3.4-dev"
                 }
             },
             "autoload": {
             ],
             "description": "Symfony Debug Component",
             "homepage": "https://symfony.com",
-            "time": "2017-11-10T16:38:39+00:00"
+            "time": "2018-01-03T17:14:19+00:00"
         },
         {
             "name": "symfony/dependency-injection",
-            "version": "v3.3.13",
+            "version": "v3.3.15",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/dependency-injection.git",
-                "reference": "4e84f5af2c2d51ee3dee72df40b7fc08f49b4ab8"
+                "reference": "e63cd8163d72a2a7722a40790ae80f2c0c8b50d1"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/dependency-injection/zipball/4e84f5af2c2d51ee3dee72df40b7fc08f49b4ab8",
-                "reference": "4e84f5af2c2d51ee3dee72df40b7fc08f49b4ab8",
+                "url": "https://api.github.com/repos/symfony/dependency-injection/zipball/e63cd8163d72a2a7722a40790ae80f2c0c8b50d1",
+                "reference": "e63cd8163d72a2a7722a40790ae80f2c0c8b50d1",
                 "shasum": ""
             },
             "require": {
                 "psr/container": "^1.0"
             },
             "conflict": {
-                "symfony/config": "<3.3.1",
+                "symfony/config": "<3.3.7",
                 "symfony/finder": "<3.3",
                 "symfony/yaml": "<3.3"
             },
             ],
             "description": "Symfony DependencyInjection Component",
             "homepage": "https://symfony.com",
-            "time": "2017-11-13T18:10:32+00:00"
+            "time": "2018-01-03T07:37:11+00:00"
         },
         {
             "name": "symfony/dom-crawler",
-            "version": "v3.3.13",
+            "version": "v3.4.3",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/dom-crawler.git",
-                "reference": "cebe3c068867956e012d9135282ba6a05d8a259e"
+                "reference": "09bd97b844b3151fab82f2fdd62db9c464b3910a"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/dom-crawler/zipball/cebe3c068867956e012d9135282ba6a05d8a259e",
-                "reference": "cebe3c068867956e012d9135282ba6a05d8a259e",
+                "url": "https://api.github.com/repos/symfony/dom-crawler/zipball/09bd97b844b3151fab82f2fdd62db9c464b3910a",
+                "reference": "09bd97b844b3151fab82f2fdd62db9c464b3910a",
                 "shasum": ""
             },
             "require": {
                 "symfony/polyfill-mbstring": "~1.0"
             },
             "require-dev": {
-                "symfony/css-selector": "~2.8|~3.0"
+                "symfony/css-selector": "~2.8|~3.0|~4.0"
             },
             "suggest": {
                 "symfony/css-selector": ""
             "type": "library",
             "extra": {
                 "branch-alias": {
-                    "dev-master": "3.3-dev"
+                    "dev-master": "3.4-dev"
                 }
             },
             "autoload": {
             ],
             "description": "Symfony DomCrawler Component",
             "homepage": "https://symfony.com",
-            "time": "2017-11-05T15:47:03+00:00"
+            "time": "2018-01-03T07:37:34+00:00"
         },
         {
             "name": "symfony/event-dispatcher",
-            "version": "v3.3.13",
+            "version": "v3.4.3",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/event-dispatcher.git",
-                "reference": "271d8c27c3ec5ecee6e2ac06016232e249d638d9"
+                "reference": "26b87b6bca8f8f797331a30b76fdae5342dc26ca"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/271d8c27c3ec5ecee6e2ac06016232e249d638d9",
-                "reference": "271d8c27c3ec5ecee6e2ac06016232e249d638d9",
+                "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/26b87b6bca8f8f797331a30b76fdae5342dc26ca",
+                "reference": "26b87b6bca8f8f797331a30b76fdae5342dc26ca",
                 "shasum": ""
             },
             "require": {
             },
             "require-dev": {
                 "psr/log": "~1.0",
-                "symfony/config": "~2.8|~3.0",
-                "symfony/dependency-injection": "~3.3",
-                "symfony/expression-language": "~2.8|~3.0",
-                "symfony/stopwatch": "~2.8|~3.0"
+                "symfony/config": "~2.8|~3.0|~4.0",
+                "symfony/dependency-injection": "~3.3|~4.0",
+                "symfony/expression-language": "~2.8|~3.0|~4.0",
+                "symfony/stopwatch": "~2.8|~3.0|~4.0"
             },
             "suggest": {
                 "symfony/dependency-injection": "",
             "type": "library",
             "extra": {
                 "branch-alias": {
-                    "dev-master": "3.3-dev"
+                    "dev-master": "3.4-dev"
                 }
             },
             "autoload": {
             ],
             "description": "Symfony EventDispatcher Component",
             "homepage": "https://symfony.com",
-            "time": "2017-11-05T15:47:03+00:00"
+            "time": "2018-01-03T07:37:34+00:00"
         },
         {
             "name": "symfony/filesystem",
-            "version": "v3.3.13",
+            "version": "v3.4.3",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/filesystem.git",
-                "reference": "77db266766b54db3ee982fe51868328b887ce15c"
+                "reference": "e078773ad6354af38169faf31c21df0f18ace03d"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/filesystem/zipball/77db266766b54db3ee982fe51868328b887ce15c",
-                "reference": "77db266766b54db3ee982fe51868328b887ce15c",
+                "url": "https://api.github.com/repos/symfony/filesystem/zipball/e078773ad6354af38169faf31c21df0f18ace03d",
+                "reference": "e078773ad6354af38169faf31c21df0f18ace03d",
                 "shasum": ""
             },
             "require": {
             "type": "library",
             "extra": {
                 "branch-alias": {
-                    "dev-master": "3.3-dev"
+                    "dev-master": "3.4-dev"
                 }
             },
             "autoload": {
             ],
             "description": "Symfony Filesystem Component",
             "homepage": "https://symfony.com",
-            "time": "2017-11-07T14:12:55+00:00"
+            "time": "2018-01-03T07:37:34+00:00"
         },
         {
             "name": "symfony/polyfill-mbstring",
         },
         {
             "name": "symfony/process",
-            "version": "v2.8.31",
+            "version": "v2.8.33",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/process.git",
-                "reference": "d25449e031f600807949aab7cadbf267712f4eee"
+                "reference": "ea3226daa3c6789efa39570bfc6e5d55f7561a0a"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/process/zipball/d25449e031f600807949aab7cadbf267712f4eee",
-                "reference": "d25449e031f600807949aab7cadbf267712f4eee",
+                "url": "https://api.github.com/repos/symfony/process/zipball/ea3226daa3c6789efa39570bfc6e5d55f7561a0a",
+                "reference": "ea3226daa3c6789efa39570bfc6e5d55f7561a0a",
                 "shasum": ""
             },
             "require": {
             ],
             "description": "Symfony Process Component",
             "homepage": "https://symfony.com",
-            "time": "2017-11-05T15:25:56+00:00"
+            "time": "2018-01-03T07:36:31+00:00"
         },
         {
             "name": "symfony/translation",
-            "version": "v3.3.13",
+            "version": "v3.3.15",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/translation.git",
-                "reference": "373e553477e55cd08f8b86b74db766c75b987fdb"
+                "reference": "96be707e96ba9ac04964d8f556d0fbb33329411c"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/translation/zipball/373e553477e55cd08f8b86b74db766c75b987fdb",
-                "reference": "373e553477e55cd08f8b86b74db766c75b987fdb",
+                "url": "https://api.github.com/repos/symfony/translation/zipball/96be707e96ba9ac04964d8f556d0fbb33329411c",
+                "reference": "96be707e96ba9ac04964d8f556d0fbb33329411c",
                 "shasum": ""
             },
             "require": {
             ],
             "description": "Symfony Translation Component",
             "homepage": "https://symfony.com",
-            "time": "2017-11-07T14:12:55+00:00"
+            "time": "2018-01-03T07:37:11+00:00"
         },
         {
             "name": "symfony/yaml",
-            "version": "v3.3.13",
+            "version": "v3.3.15",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/yaml.git",
-                "reference": "0938408c4faa518d95230deabb5f595bf0de31b9"
+                "reference": "7c80d81b5805589be151b85b0df785f0dc3269cf"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/yaml/zipball/0938408c4faa518d95230deabb5f595bf0de31b9",
-                "reference": "0938408c4faa518d95230deabb5f595bf0de31b9",
+                "url": "https://api.github.com/repos/symfony/yaml/zipball/7c80d81b5805589be151b85b0df785f0dc3269cf",
+                "reference": "7c80d81b5805589be151b85b0df785f0dc3269cf",
                 "shasum": ""
             },
             "require": {
             ],
             "description": "Symfony Yaml Component",
             "homepage": "https://symfony.com",
-            "time": "2017-11-10T18:26:04+00:00"
+            "time": "2018-01-03T07:37:11+00:00"
         },
         {
             "name": "theseer/tokenizer",
index 4ffe172..3155ddf 100644 (file)
@@ -205,8 +205,11 @@ class course_edit_form extends moodleform {
         $languages=array();
         $languages[''] = get_string('forceno');
         $languages += get_string_manager()->get_list_of_translations();
-        $mform->addElement('select', 'lang', get_string('forcelanguage'), $languages);
-        $mform->setDefault('lang', $courseconfig->lang);
+        if ((empty($course->id) && guess_if_creator_will_have_course_capability('moodle/course:setforcedlanguage', $categorycontext))
+                || (!empty($course->id) && has_capability('moodle/course:setforcedlanguage', $coursecontext))) {
+            $mform->addElement('select', 'lang', get_string('forcelanguage'), $languages);
+            $mform->setDefault('lang', $courseconfig->lang);
+        }
 
         // Multi-Calendar Support - see MDL-18375.
         $calendartypes = \core_calendar\type_factory::get_list_of_calendar_types();
index 012f8f7..62e868f 100644 (file)
@@ -713,8 +713,13 @@ class core_course_external extends external_api {
             require_capability('moodle/course:create', $context);
 
             // Make sure lang is valid
-            if (array_key_exists('lang', $course) and empty($availablelangs[$course['lang']])) {
-                throw new moodle_exception('errorinvalidparam', 'webservice', '', 'lang');
+            if (array_key_exists('lang', $course)) {
+                if (empty($availablelangs[$course['lang']])) {
+                    throw new moodle_exception('errorinvalidparam', 'webservice', '', 'lang');
+                }
+                if (!has_capability('moodle/course:setforcedlanguage', $context)) {
+                    unset($course['lang']);
+                }
             }
 
             // Make sure theme is valid
@@ -911,8 +916,11 @@ class core_course_external extends external_api {
                 }
 
                 // Make sure lang is valid.
-                if (array_key_exists('lang', $course) && empty($availablelangs[$course['lang']])) {
-                    throw new moodle_exception('errorinvalidparam', 'webservice', '', 'lang');
+                if (array_key_exists('lang', $course) && ($oldcourse->lang != $course['lang'])) {
+                    require_capability('moodle/course:setforcedlanguage', $context);
+                    if (empty($availablelangs[$course['lang']])) {
+                        throw new moodle_exception('errorinvalidparam', 'webservice', '', 'lang');
+                    }
                 }
 
                 // Make sure theme is valid.
index 2b6e4bd..ba36ab0 100644 (file)
@@ -412,6 +412,7 @@ class core_course_externallib_testcase extends externallib_advanced_testcase {
         $contextid = context_system::instance()->id;
         $roleid = $this->assignUserCapability('moodle/course:create', $contextid);
         $this->assignUserCapability('moodle/course:visibility', $contextid, $roleid);
+        $this->assignUserCapability('moodle/course:setforcedlanguage', $contextid, $roleid);
 
         $category  = self::getDataGenerator()->create_category();
 
@@ -1129,6 +1130,7 @@ class core_course_externallib_testcase extends externallib_advanced_testcase {
         $this->assignUserCapability('moodle/course:changesummary', $contextid, $roleid);
         $this->assignUserCapability('moodle/course:visibility', $contextid, $roleid);
         $this->assignUserCapability('moodle/course:viewhiddencourses', $contextid, $roleid);
+        $this->assignUserCapability('moodle/course:setforcedlanguage', $contextid, $roleid);
 
         // Create category and course.
         $category1  = self::getDataGenerator()->create_category();
index 7f0cc37..a181c44 100644 (file)
@@ -1,6 +1,11 @@
 This files describes API changes in /course/*,
 information provided here is intended especially for developers.
 
+=== 3.5 ===
+ * There is a new capability 'moodle/course:forcelanguage' to control which users can force the course
+   language; create_course and update_course functions delegate access control to the caller code; if you
+   are calling those functions you may be interested in checking if the logged in user has 'moodle/course:forcelanguage' capability.
+
 === 3.3 ===
 
  * External function core_course_external::get_courses_by_field now returns the course filters list and status.
index 7a48f3b..4a49dd7 100644 (file)
@@ -218,7 +218,7 @@ $string['configenablecourserequests'] = 'This will allow any user to request a c
 $string['configenablemobilewebservice'] = 'Enable mobile service for the official Moodle app or other app requesting it. For more information, read the {$a}';
 $string['configenablerssfeeds'] = 'If enabled, RSS feeds are generated by various features across the site, such as blogs, forums, database activities and glossaries. Note that RSS feeds also need to be enabled for the particular activity modules.';
 $string['configenablerssfeedsdisabled'] = 'It is not available because RSS feeds are disabled in all the Site. To enable them, go to the Variables settings under Admin Configuration.';
-$string['configenablerssfeedsdisabled2'] = 'RSS feeds are disabled at the server level. You need to enable them first in Server/RSS.';
+$string['configenablerssfeedsdisabled2'] = 'RSS feeds are disabled at the server level. You need to enable them first in Advanced Features / Enable RSS feeds.';
 $string['configenablesafebrowserintegration'] = 'This adds the choice \'Require Safe Exam Browser\' to the \'Browser security\' field on the quiz settings form. See http://www.safeexambrowser.org/ for more information.';
 $string['configenablestats'] = 'If you choose \'yes\' here, Moodle\'s cronjob will process the logs and gather some statistics.  Depending on the amount of traffic on your site, this can take awhile. If you enable this, you will be able to see some interesting graphs and statistics about each of your courses, or on a sitewide basis.';
 $string['configenabletrusttext'] = 'By default Moodle will always thoroughly clean text that comes from users to remove any possible bad scripts, media etc that could be a security risk.  The Trusted Content system is a way of giving particular users that you trust the ability to include these advanced features in their content without interference.  To enable this system, you need to first enable this setting, and then grant the Trusted Content permission to a specific Moodle role.  Texts created or uploaded by such users will be marked as trusted and will not be cleaned before display.';
index 80b20b6..207a522 100644 (file)
@@ -710,6 +710,7 @@ $string['emailresetconfirmsent'] = 'An email has been sent to your address at <b
 If you continue to have difficulty, contact the site administrator.';
 $string['emailtoprivatefiles'] = 'You can also e-mail files as attachments straight to your private files space. Simply attach your files to an e-mail and send it to {$a}';
 $string['emailtoprivatefilesdenied'] = 'Your administrator has disabled the option to upload your own private files.';
+$string['emailuserhasnone'] = 'There is no email address for the user.';
 $string['emailvia'] = '{$a->name} (via {$a->siteshortname})';
 $string['emptydragdropregion'] = 'empty region';
 $string['enable'] = 'Enable';
index b3c377d..e56d5d0 100644 (file)
@@ -41,6 +41,8 @@ $string['cannotdeletecate'] = 'You can\'t delete that category it is the default
 $string['cannotdeleteneededbehaviour'] = 'Cannot delete the question behaviour \'{$a}\'. There are other behaviours installed that rely on it.';
 $string['cannotdeleteqtypeinuse'] = 'You cannot delete the question type \'{$a}\'. There are questions of this type in the question bank.';
 $string['cannotdeleteqtypeneeded'] = 'You cannot delete the question type \'{$a}\'. There are other question types installed that rely on it.';
+$string['cannotdeletetopcat'] = 'Top categories can not be deleted.';
+$string['cannotedittopcat'] = 'Top categories can not be edited.';
 $string['cannotenable'] = 'Question type {$a} cannot be created directly.';
 $string['cannotenablebehaviour'] = 'Question behaviour {$a} cannot be used directly. It is for internal use only.';
 $string['cannotfindcate'] = 'Could not find category record';
@@ -269,6 +271,7 @@ $string['questionsinuse'] = '(* Questions marked by an asterisk are already in u
 $string['questionsmovedto'] = 'Questions still in use moved to "{$a}" in the parent course category.';
 $string['questionsrescuedfrom'] = 'Questions saved from context {$a}.';
 $string['questionsrescuedfrominfo'] = 'These questions (some of which may be hidden) were saved when context {$a} was deleted because they are still used by some quizzes or other activities.';
+$string['questiontags'] = 'Question tags';
 $string['questiontype'] = 'Question type';
 $string['questionuse'] = 'Use question in this activity';
 $string['questionvariant'] = 'Question variant';
@@ -397,6 +400,7 @@ $string['requiresgrading'] = 'Requires grading';
 $string['responsehistory'] = 'Response history';
 $string['restart'] = 'Start again';
 $string['restartwiththeseoptions'] = 'Start again with these options';
+$string['restoremultipletopcats'] = 'The backup file contains more than one top-level question categories for context {$a}.';
 $string['rightanswer'] = 'Right answer';
 $string['rightanswer_help'] = 'an automatically generated summary of the correct response. This can be limited, so you may wish to consider explaining the correct solution in the general feedback for the question, and turning this option off.';
 $string['saved'] = 'Saved: {$a}';
index f35cf01..cfc5699 100644 (file)
@@ -164,6 +164,7 @@ $string['course:changeshortname'] = 'Change course short name';
 $string['course:changesummary'] = 'Change course summary';
 $string['course:enrolconfig'] = 'Configure enrol instances in courses';
 $string['course:enrolreview'] = 'Review course enrolments';
+$string['course:setforcedlanguage'] = 'Force course language';
 $string['course:ignoreavailabilityrestrictions'] = 'Ignore availability restrictions';
 $string['course:ignorefilesizelimits'] = 'Use files larger than any file size restrictions';
 $string['course:isincompletionreports'] = 'Be shown on completion reports';
index d7e858a..d91d0c7 100644 (file)
@@ -372,6 +372,7 @@ class icon_system_fontawesome extends icon_system_font {
             'core:t/switch_minus' => 'fa-minus',
             'core:t/switch_plus' => 'fa-plus',
             'core:t/switch_whole' => 'fa-square-o',
+            'core:t/tags' => 'fa-tags',
             'core:t/unblock' => 'fa-commenting',
             'core:t/unlocked' => 'fa-unlock-alt',
             'core:t/unlock' => 'fa-lock',
index 323747d..2efdccf 100644 (file)
@@ -1062,6 +1062,16 @@ $capabilities = array(
         'clonepermissionsfrom' => 'moodle/course:update'
     ),
 
+    'moodle/course:setforcedlanguage' => array(
+        'captype' => 'write',
+        'contextlevel' => CONTEXT_COURSE,
+        'archetypes' => array(
+            'editingteacher' => CAP_ALLOW,
+            'manager' => CAP_ALLOW
+        ),
+        'clonepermissionsfrom' => 'moodle/course:update'
+    ),
+
 
     'moodle/site:viewparticipants' => array(
 
index e8e4227..3a712b7 100644 (file)
@@ -1114,6 +1114,13 @@ $functions = array(
         'capabilities'  => 'moodle/question:flag',
         'services'      => array(MOODLE_OFFICIAL_MOBILE_SERVICE),
     ),
+    'core_question_submit_tags_form' => array(
+        'classname'     => 'core_question_external',
+        'methodname'    => 'submit_tags_form',
+        'description'   => 'Update the question tags.',
+        'type'          => 'write',
+        'ajax' => true,
+    ),
     'core_rating_get_item_ratings' => array(
         'classname' => 'core_rating_external',
         'methodname' => 'get_item_ratings',
index 504fbfd..6b6dc90 100644 (file)
@@ -1935,5 +1935,43 @@ function xmldb_main_upgrade($oldversion) {
         upgrade_main_savepoint(true, 2017122200.01);
     }
 
+    if ($oldversion < 2018020500.00) {
+
+        $topcategory = new stdClass();
+        $topcategory->name = 'top'; // A non-real name for the top category. It will be localised at the display time.
+        $topcategory->info = '';
+        $topcategory->parent = 0;
+        $topcategory->sortorder = 0;
+
+        // Get the total record count - used for the progress bar.
+        $total = $DB->count_records_sql("SELECT COUNT(DISTINCT contextid) FROM {question_categories} WHERE parent = 0");
+
+        // Get the records themselves - a list of contextids.
+        $rs = $DB->get_recordset_sql("SELECT DISTINCT contextid FROM {question_categories} WHERE parent = 0");
+
+        // For each context, create a single top-level category.
+        $i = 0;
+        $pbar = new progress_bar('createtopquestioncategories', 500, true);
+        foreach ($rs as $contextid => $notused) {
+            $topcategory->contextid = $contextid;
+            $topcategory->stamp = make_unique_id_code();
+
+            $topcategoryid = $DB->insert_record('question_categories', $topcategory);
+
+            $DB->set_field_select('question_categories', 'parent', $topcategoryid,
+                    'contextid = ? AND id <> ? AND parent = 0',
+                    array($contextid, $topcategoryid));
+
+            // Update progress.
+            $i++;
+            $pbar->update($i, $total, "Creating top-level question categories - $i/$total.");
+        }
+
+        $rs->close();
+
+        // Main savepoint reached.
+        upgrade_main_savepoint(true, 2018020500.00);
+    }
+
     return true;
 }
index 0f34ef2..9528c10 100644 (file)
@@ -6534,3 +6534,45 @@ function allow_switch($fromroleid, $targetroleid) {
 
     core_role_set_switch_allowed($fromroleid, $targetroleid);
 }
+
+/**
+ * Organise categories into a single parent category (called the 'Top' category) per context.
+ *
+ * @param array $categories List of question categories in the format of ["$categoryid,$contextid" => $category].
+ * @param array $pcontexts List of context ids.
+ * @return array
+ * @deprecated since Moodle 3.5. MDL-61132
+ */
+function question_add_tops($categories, $pcontexts) {
+    debugging('question_add_tops() has been deprecated. You may want to pass $top = true to get_categories_for_contexts().',
+            DEBUG_DEVELOPER);
+
+    $topcats = array();
+    foreach ($pcontexts as $context) {
+        $topcat = question_get_top_category($context, true);
+
+        $newcat = new stdClass();
+        $newcat->id = "{$topcat->id},$context";
+        $newcat->name = get_string('top');
+        $newcat->parent = 0;
+        $newcat->contextid = $context;
+        $topcats["{$topcat->id},$context"] = $newcat;
+    }
+    // Put topcats in at beginning of array - they'll be sorted into different contexts later.
+    return array_merge($topcats, $categories);
+}
+
+/**
+ * Checks if the question category is the highest-level category in the context that can be edited, and has no siblings.
+ *
+ * @param int $categoryid a category id.
+ * @return bool
+ * @deprecated since Moodle 3.5. MDL-61132
+ */
+function question_is_only_toplevel_category_in_context($categoryid) {
+    debugging('question_is_only_toplevel_category_in_context() has been deprecated. '
+            . 'Please update your code to use question_is_only_child_of_top_category_in_context() instead.',
+            DEBUG_DEVELOPER);
+
+    return question_is_only_child_of_top_category_in_context($categoryid);
+}
index 57021a7..904975f 100644 (file)
@@ -971,7 +971,6 @@ function add_indented_names($categories, $nochildrenof = -1) {
  */
 function question_category_select_menu($contexts, $top = false, $currentcat = 0,
         $selected = "", $nochildrenof = -1) {
-    global $OUTPUT;
     $categoriesarray = question_category_options($contexts, $top, $currentcat,
             false, $nochildrenof);
     if ($selected) {
@@ -994,8 +993,8 @@ function question_category_select_menu($contexts, $top = false, $currentcat = 0,
  */
 function question_get_default_category($contextid) {
     global $DB;
-    $category = $DB->get_records('question_categories',
-            array('contextid' => $contextid), 'id', '*', 0, 1);
+    $category = $DB->get_records_select('question_categories', 'contextid = ? AND parent <> 0',
+            array($contextid), 'id', '*', 0, 1);
     if (!empty($category)) {
         return reset($category);
     } else {
@@ -1003,6 +1002,51 @@ function question_get_default_category($contextid) {
     }
 }
 
+/**
+ * Gets the top category in the given context.
+ * This function can optionally create the top category if it doesn't exist.
+ *
+ * @param int $contextid A context id.
+ * @param bool $create Whether create a top category if it doesn't exist.
+ * @return bool|stdClass The top question category for that context, or false if none.
+ */
+function question_get_top_category($contextid, $create = false) {
+    global $DB;
+    $category = $DB->get_record('question_categories',
+            array('contextid' => $contextid, 'parent' => 0));
+
+    if (!$category && $create) {
+        // We need to make one.
+        $category = new stdClass();
+        $category->name = 'top'; // A non-real name for the top category. It will be localised at the display time.
+        $category->info = '';
+        $category->contextid = $contextid;
+        $category->parent = 0;
+        $category->sortorder = 0;
+        $category->stamp = make_unique_id_code();
+        $category->id = $DB->insert_record('question_categories', $category);
+    }
+
+    return $category;
+}
+
+/**
+ * Gets the list of top categories in the given contexts in the array("categoryid,categorycontextid") format.
+ *
+ * @param array $contextids List of context ids
+ * @return array
+ */
+function question_get_top_categories_for_contexts($contextids) {
+    global $DB;
+
+    $concatsql = $DB->sql_concat_join("','", ['id', 'contextid']);
+    list($insql, $params) = $DB->get_in_or_equal($contextids);
+    $sql = "SELECT $concatsql FROM {question_categories} WHERE contextid $insql AND parent = 0";
+    $topcategories = $DB->get_fieldset_sql($sql, $params);
+
+    return $topcategories;
+}
+
 /**
  * Gets the default category in the most specific context.
  * If no categories exist yet then default ones are created in all contexts.
@@ -1023,15 +1067,16 @@ function question_make_default_categories($contexts) {
     $preferredness = 0;
     // If it already exists, just return it.
     foreach ($contexts as $key => $context) {
+        $topcategory = question_get_top_category($context->id, true);
         if (!$exists = $DB->record_exists("question_categories",
-                array('contextid' => $context->id))) {
+                array('contextid' => $context->id, 'parent' => $topcategory->id))) {
             // Otherwise, we need to make one
             $category = new stdClass();
             $contextname = $context->get_context_name(false, true);
             $category->name = get_string('defaultfor', 'question', $contextname);
             $category->info = get_string('defaultinfofor', 'question', $contextname);
             $category->contextid = $context->id;
-            $category->parent = 0;
+            $category->parent = $topcategory->id;
             // By default, all categories get this number, and are sorted alphabetically.
             $category->sortorder = 999;
             $category->stamp = make_unique_id_code();
@@ -1061,20 +1106,29 @@ function question_make_default_categories($contexts) {
  *
  * @param mixed $contexts either a single contextid, or a comma-separated list of context ids.
  * @param string $sortorder used as the ORDER BY clause in the select statement.
+ * @param bool $top Whether to return the top categories or not.
  * @return array of category objects.
  */
-function get_categories_for_contexts($contexts, $sortorder = 'parent, sortorder, name ASC') {
+function get_categories_for_contexts($contexts, $sortorder = 'parent, sortorder, name ASC', $top = false) {
     global $DB;
+    $topwhere = $top ? '' : 'AND c.parent <> 0';
     return $DB->get_records_sql("
             SELECT c.*, (SELECT count(1) FROM {question} q
                         WHERE c.id = q.category AND q.hidden='0' AND q.parent='0') AS questioncount
               FROM {question_categories} c
-             WHERE c.contextid IN ($contexts)
+             WHERE c.contextid IN ($contexts) $topwhere
           ORDER BY $sortorder");
 }
 
 /**
  * Output an array of question categories.
+ *
+ * @param array $contexts The list of contexts.
+ * @param bool $top Whether to return the top categories or not.
+ * @param int $currentcat
+ * @param bool $popupform
+ * @param int $nochildrenof
+ * @return array
  */
 function question_category_options($contexts, $top = false, $currentcat = 0,
         $popupform = false, $nochildrenof = -1) {
@@ -1085,13 +1139,13 @@ function question_category_options($contexts, $top = false, $currentcat = 0,
     }
     $contextslist = join($pcontexts, ', ');
 
-    $categories = get_categories_for_contexts($contextslist);
-
-    $categories = question_add_context_in_key($categories);
+    $categories = get_categories_for_contexts($contextslist, 'parent, sortorder, name ASC', $top);
 
     if ($top) {
-        $categories = question_add_tops($categories, $pcontexts);
+        $categories = question_fix_top_names($categories);
     }
+
+    $categories = question_add_context_in_key($categories);
     $categories = add_indented_names($categories, $nochildrenof);
 
     // sort cats out into different contexts
@@ -1138,18 +1192,21 @@ function question_add_context_in_key($categories) {
     return $newcatarray;
 }
 
-function question_add_tops($categories, $pcontexts) {
-    $topcats = array();
-    foreach ($pcontexts as $context) {
-        $newcat = new stdClass();
-        $newcat->id = "0,$context";
-        $newcat->name = get_string('top');
-        $newcat->parent = -1;
-        $newcat->contextid = $context;
-        $topcats["0,$context"] = $newcat;
-    }
-    //put topcats in at beginning of array - they'll be sorted into different contexts later.
-    return array_merge($topcats, $categories);
+/**
+ * Finds top categories in the given categories hierarchy and replace their name with a proper localised string.
+ *
+ * @param array $categories An array of question categories.
+ * @return array The same question category list given to the function, with the top category names being translated.
+ */
+function question_fix_top_names($categories) {
+
+    foreach ($categories as $id => $category) {
+        if ($category->parent == 0) {
+            $categories[$id]->name = get_string('top');
+        }
+    }
+
+    return $categories;
 }
 
 /**
index b699f72..da09a5b 100644 (file)
@@ -249,8 +249,8 @@ class core_questionlib_testcase extends advanced_testcase {
         $rc->execute_plan();
 
         // Get the created question category.
-        $restoredcategory = $DB->get_record('question_categories', array('contextid' => context_course::instance($course2->id)->id),
-            '*', MUST_EXIST);
+        $restoredcategory = $DB->get_record_select('question_categories', 'contextid = ? AND parent <> 0',
+                array(context_course::instance($course2->id)->id), '*', MUST_EXIST);
 
         // Check that there are two questions in the restored to course's context.
         $this->assertEquals(2, $DB->count_records('question', array('category' => $restoredcategory->id)));
@@ -334,6 +334,7 @@ class core_questionlib_testcase extends advanced_testcase {
         $this->assertEquals(0, $DB->count_records('question', $criteria));
 
         // Test that the feedback works.
+        $expected[] = array('top', get_string('unusedcategorydeleted', 'question'));
         $expected[] = array($qcat->name, get_string('unusedcategorydeleted', 'question'));
         $this->assertEquals($expected, $result);
     }
index 4d84daf..e3a9b58 100644 (file)
@@ -3140,13 +3140,14 @@ class assign {
      * Render the content in editor that is often used by plugin.
      *
      * @param string $filearea
-     * @param int  $submissionid
+     * @param int $submissionid
      * @param string $plugintype
      * @param string $editor
      * @param string $component
+     * @param bool $shortentext Whether to shorten the text content.
      * @return string
      */
-    public function render_editor_content($filearea, $submissionid, $plugintype, $editor, $component) {
+    public function render_editor_content($filearea, $submissionid, $plugintype, $editor, $component, $shortentext = false) {
         global $CFG;
 
         $result = '';
@@ -3154,6 +3155,9 @@ class assign {
         $plugin = $this->get_submission_plugin_by_type($plugintype);
 
         $text = $plugin->get_editor_text($editor, $submissionid);
+        if ($shortentext) {
+            $text = shorten_text($text, 140);
+        }
         $format = $plugin->get_editor_format($editor, $submissionid);
 
         $finaltext = file_rewrite_pluginfile_urls($text,
index 8afd96f..85ea4dc 100644 (file)
@@ -347,14 +347,18 @@ class assign_submission_onlinetext extends assign_submission_plugin {
         $showviewlink = true;
 
         if ($onlinetextsubmission) {
+            // This contains the shortened version of the text plus an optional 'Export to portfolio' button.
             $text = $this->assignment->render_editor_content(ASSIGNSUBMISSION_ONLINETEXT_FILEAREA,
                                                              $onlinetextsubmission->submission,
                                                              $this->get_type(),
                                                              'onlinetext',
-                                                             'assignsubmission_onlinetext');
+                                                             'assignsubmission_onlinetext', true);
 
+            // The actual submission text.
             $onlinetext = trim($onlinetextsubmission->onlinetext);
-            $shorttext = shorten_text($text, 140);
+            // The shortened version of the submission text.
+            $shorttext = shorten_text($onlinetext, 140);
+
             $plagiarismlinks = '';
 
             if (!empty($CFG->enableplagiarism)) {
@@ -366,12 +370,13 @@ class assign_submission_onlinetext extends assign_submission_plugin {
                     'course' => $this->assignment->get_course()->id,
                     'assignment' => $submission->assignment));
             }
-            if ($text != $shorttext) {
+            // We compare the actual text submission and the shortened version. If they are not equal, we show the word count.
+            if ($onlinetext != $shorttext) {
                 $wordcount = get_string('numwords', 'assignsubmission_onlinetext', count_words($onlinetext));
 
-                return $plagiarismlinks . $wordcount . $shorttext;
+                return $plagiarismlinks . $wordcount . $text;
             } else {
-                return $plagiarismlinks . $shorttext;
+                return $plagiarismlinks . $text;
             }
         }
         return '';
index 658ecb8..144fe2e 100644 (file)
@@ -85,6 +85,10 @@ if ($data = $mform->get_data()) {
     if (!empty($data->existingcategory)) {
         list($categoryid) = explode(',', $data->category);
         $includesubcategories = !empty($data->includesubcategories);
+        if (!$includesubcategories) {
+            // If the chosen category is a top category.
+            $includesubcategories = $DB->record_exists('question_categories', ['id' => $categoryid, 'parent' => 0]);
+        }
         $returnurl->param('cat', $data->category);
 
     } else if (!empty($data->newcategory)) {
index f66832e..7405dda 100644 (file)
@@ -37,7 +37,6 @@ require_once($CFG->libdir.'/formslib.php');
 class quiz_add_random_form extends moodleform {
 
     protected function definition() {
-        global $CFG, $DB;
         $mform =& $this->_form;
         $mform->setDisableShortforms();
 
@@ -49,11 +48,14 @@ class quiz_add_random_form extends moodleform {
                 get_string('randomfromexistingcategory', 'quiz'));
 
         $mform->addElement('questioncategory', 'category', get_string('category'),
-                array('contexts' => $usablecontexts, 'top' => false));
+                array('contexts' => $usablecontexts, 'top' => true));
         $mform->setDefault('category', $this->_customdata['cat']);
 
         $mform->addElement('checkbox', 'includesubcategories', '', get_string('recurse', 'quiz'));
 
+        $tops = question_get_top_categories_for_contexts(array_column($contexts->all(), 'id'));
+        $mform->hideIf('includesubcategories', 'category', 'in', $tops);
+
         $mform->addElement('select', 'numbertoadd', get_string('randomnumber', 'quiz'),
                 $this->get_number_of_questions_to_add_choices());
 
index bd4dcfc..2b7d741 100644 (file)
@@ -177,17 +177,15 @@ foreach ($overrides as $override) {
     // Icons.
     $iconstr = '';
 
-    if ($active) {
-        // Edit.
-        $editurlstr = $overrideediturl->out(true, array('id' => $override->id));
-        $iconstr = '<a title="' . get_string('edit') . '" href="'. $editurlstr . '">' .
-                $OUTPUT->pix_icon('t/edit', get_string('edit')) . '</a> ';
-        // Duplicate.
-        $copyurlstr = $overrideediturl->out(true,
-                array('id' => $override->id, 'action' => 'duplicate'));
-        $iconstr .= '<a title="' . get_string('copy') . '" href="' . $copyurlstr . '">' .
-                $OUTPUT->pix_icon('t/copy', get_string('copy')) . '</a> ';
-    }
+    // Edit.
+    $editurlstr = $overrideediturl->out(true, array('id' => $override->id));
+    $iconstr = '<a title="' . get_string('edit') . '" href="'. $editurlstr . '">' .
+            $OUTPUT->pix_icon('t/edit', get_string('edit')) . '</a> ';
+    // Duplicate.
+    $copyurlstr = $overrideediturl->out(true,
+            array('id' => $override->id, 'action' => 'duplicate'));
+    $iconstr .= '<a title="' . get_string('copy') . '" href="' . $copyurlstr . '">' .
+            $OUTPUT->pix_icon('t/copy', get_string('copy')) . '</a> ';
     // Delete.
     $deleteurlstr = $overridedeleteurl->out(true,
             array('id' => $override->id, 'sesskey' => sesskey()));
diff --git a/mod/quiz/tests/behat/quiz_user_override.feature b/mod/quiz/tests/behat/quiz_user_override.feature
new file mode 100644 (file)
index 0000000..4b7a5bf
--- /dev/null
@@ -0,0 +1,73 @@
+@mod @mod_quiz @javascript
+Feature: Quiz user override
+  In order to grant a student special access to a quiz
+  As a teacher
+  I need to create an override for that user.
+
+  Background:
+    Given the following "users" exist:
+      | username | firstname | lastname | email                |
+      | teacher1 | Teacher   | One      | teacher1@example.com |
+      | student1 | Student   | One      | student1@example.com |
+      | student2 | Student   | Two      | student2@example.com |
+    And the following "courses" exist:
+      | fullname | shortname | category |
+      | Course 1 | C1        | 0        |
+    And the following "course enrolments" exist:
+      | user     | course | role           |
+      | teacher1 | C1     | editingteacher |
+      | student1 | C1     | student        |
+      | student2 | C1     | student        |
+    And the following "question categories" exist:
+      | contextlevel | reference | name           |
+      | Course       | C1        | Test questions |
+    And the following "activities" exist:
+      | activity   | name   | intro              | course | idnumber |
+      | quiz       | Quiz 1 | Quiz 1 description | C1     | quiz1    |
+    And the following "questions" exist:
+      | questioncategory | qtype       | name  | questiontext    |
+      | Test questions   | truefalse   | TF1   | First question  |
+      | Test questions   | truefalse   | TF2   | Second question |
+    And quiz "Quiz 1" contains the following questions:
+      | question | page | maxmark |
+      | TF1      | 1    |         |
+      | TF2      | 1    | 3.0     |
+    And I log in as "teacher1"
+    And I am on "Course 1" course homepage
+
+  Scenario: Add, modify then delete a user override
+    When I follow "Quiz 1"
+    And I navigate to "User overrides" in current page administration
+    And I press "Add user override"
+    And I set the following fields to these values:
+      | Override user        | Student1 |
+      | id_timeclose_enabled | 1        |
+      | timeclose[day]       | 1        |
+      | timeclose[month]     | January  |
+      | timeclose[year]      | 2020     |
+      | timeclose[hour]      | 08       |
+      | timeclose[minute]    | 00       |
+    And I press "Save"
+    And I should see "Wednesday, 1 January 2020, 8:00"
+    Then I click on "Edit" "link" in the "Student One" "table_row"
+    And I set the following fields to these values:
+      | timeclose[year] | 2030 |
+    And I press "Save"
+    And I should see "Tuesday, 1 January 2030, 8:00"
+    And I click on "Delete" "link"
+    And I press "Continue"
+    And I should not see "Student One"
+
+  Scenario: Being able to modify a user override when the quiz is not available to the student
+    Given I follow "Quiz 1"
+    And I navigate to "Edit settings" in current page administration
+    And I expand all fieldsets
+    And I set the field "Availability" to "Hide from students"
+    And I click on "Save and display" "button"
+    When I navigate to "User overrides" in current page administration
+    And I press "Add user override"
+    And I set the following fields to these values:
+      | Override user    | Student1 |
+      | Attempts allowed | 1        |
+    And I press "Save"
+    Then "Edit" "icon" should exist in the "Student One" "table_row"
diff --git a/question/amd/build/edit_tags.min.js b/question/amd/build/edit_tags.min.js
new file mode 100644 (file)
index 0000000..cf0aed5
Binary files /dev/null and b/question/amd/build/edit_tags.min.js differ
diff --git a/question/amd/build/repository.min.js b/question/amd/build/repository.min.js
new file mode 100644 (file)
index 0000000..35de00b
Binary files /dev/null and b/question/amd/build/repository.min.js differ
diff --git a/question/amd/build/selectors.min.js b/question/amd/build/selectors.min.js
new file mode 100644 (file)
index 0000000..ffe642d
Binary files /dev/null and b/question/amd/build/selectors.min.js differ
diff --git a/question/amd/src/edit_tags.js b/question/amd/src/edit_tags.js
new file mode 100644 (file)
index 0000000..83d5f9a
--- /dev/null
@@ -0,0 +1,229 @@
+// 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/>.
+
+/**
+ * A javascript module to handle question tags editing.
+ *
+ * @module     core_question/edit_tags
+ * @copyright  2018 Simey Lameze <simey@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+define([
+            'jquery',
+            'core/fragment',
+            'core/str',
+            'core/modal_events',
+            'core/modal_factory',
+            'core/notification',
+            'core/custom_interaction_events',
+            'core_question/repository',
+            'core_question/selectors',
+        ],
+        function(
+            $,
+            Fragment,
+            Str,
+            ModalEvents,
+            ModalFactory,
+            Notification,
+            CustomEvents,
+            Repository,
+            QuestionSelectors
+        ) {
+
+    /**
+     * Enable the save button in the footer.
+     *
+     * @param {object} root The container element.
+     * @method enableSaveButton
+     */
+    var enableSaveButton = function(root) {
+        root.find(QuestionSelectors.actions.save).prop('disabled', false);
+    };
+
+    /**
+     * Disable the save button in the footer.
+     *
+     * @param {object} root The container element.
+     * @method disableSaveButton
+     */
+    var disableSaveButton = function(root) {
+        root.find(QuestionSelectors.actions.save).prop('disabled', true);
+    };
+
+    /**
+     * Get the serialised form data.
+     *
+     * @method getFormData
+     * @param {object} modal The modal object.
+     * @return {string} serialised form data
+     */
+    var getFormData = function(modal) {
+        return modal.getBody().find('form').serialize();
+    };
+
+    /**
+     * Set the element state to loading.
+     *
+     * @param {object} root The container element
+     * @method startLoading
+     */
+    var startLoading = function(root) {
+        var loadingIconContainer = root.find(QuestionSelectors.containers.loadingIcon);
+
+        loadingIconContainer.removeClass('hidden');
+    };
+
+    /**
+     * Remove the loading state from the element.
+     *
+     * @param {object} root The container element
+     * @method stopLoading
+     */
+    var stopLoading = function(root) {
+        var loadingIconContainer = root.find(QuestionSelectors.containers.loadingIcon);
+
+        loadingIconContainer.addClass('hidden');
+    };
+
+    /**
+     * Register event listeners for the module.
+     *
+     * @param {object} root The calendar root element
+     */
+    var registerEventListeners = function(root) {
+        var modalPromise = ModalFactory.create(
+            {
+                type: ModalFactory.types.SAVE_CANCEL,
+                large: false
+            },
+            [root, QuestionSelectors.actions.edittags]
+        ).then(function(modal) {
+            // All of this code only executes once, when the modal is
+            // first created. This allows us to add any code that should
+            // only be run once, such as adding event handlers to the modal.
+            Str.get_string('questiontags', 'question')
+                .then(function(string) {
+                    modal.setTitle(string);
+                    return string;
+                })
+                .fail(Notification.exception);
+
+            modal.getRoot().on(ModalEvents.save, function(e) {
+                var form = modal.getBody().find('form');
+                form.submit();
+                e.preventDefault();
+            });
+
+            modal.getRoot().on('submit', 'form', function(e) {
+                save(modal, root).then(function() {
+                    modal.hide();
+                    return;
+                }).fail(Notification.exception);
+
+                // Stop the form from actually submitting and prevent it's
+                // propagation because we have already handled the event.
+                e.preventDefault();
+                e.stopPropagation();
+            });
+
+            return modal;
+        });
+
+        // We need to add an event handler to the tags link because there are
+        // multiple links on the page and without adding a listener we don't know
+        // which one the user clicked on the show the modal.
+        root.on(CustomEvents.events.activate, QuestionSelectors.actions.edittags, function(e) {
+            var currentTarget = $(e.currentTarget);
+
+            var questionId = currentTarget.data('questionid'),
+                canEdit = !!currentTarget.data('canedit'),
+                contextId = currentTarget.data('contextid');
+
+            // This code gets called each time the user clicks the tag link
+            // so we can use it to reload the contents of the tag modal.
+            modalPromise.then(function(modal) {
+                // Display spinner and disable save button.
+                disableSaveButton(root);
+                startLoading(root);
+
+                var args = {
+                    id: questionId
+                };
+
+                var tagsFragment = Fragment.loadFragment('question', 'tags_form', contextId, args);
+                modal.setBody(tagsFragment);
+
+                tagsFragment.then(function() {
+                        enableSaveButton(root);
+                        return;
+                    })
+                    .always(function() {
+                        // Always hide the loading spinner when the request
+                        // has completed.
+                        stopLoading(root);
+                        return;
+                    })
+                .fail(Notification.exception);
+
+                // Show or hide the save button depending on whether the user
+                // has the capability to edit the tags.
+                if (canEdit) {
+                    modal.getRoot().find(QuestionSelectors.actions.save).show();
+                } else {
+                    modal.getRoot().find(QuestionSelectors.actions.save).hide();
+                }
+
+                return modal;
+            }).fail(Notification.exception);
+
+            e.preventDefault();
+        });
+    };
+
+    /**
+     * Send the form data to the server to save question tags.
+     *
+     * @method save
+     * @param {object} modal The modal object.
+     * @param {object} root The container element.
+     * @return {object} A promise
+     */
+    var save = function(modal, root) {
+        // Display spinner and disable save button.
+        disableSaveButton(root);
+        startLoading(root);
+
+        var formData = getFormData(modal);
+
+        // Send the form data to the server for processing.
+        return Repository.submitTagCreateUpdateForm(formData)
+            .always(function() {
+                // Regardless of success or error we should always stop
+                // the loading icon and re-enable the buttons.
+                stopLoading(root);
+                enableSaveButton(root);
+                return;
+            })
+            .fail(Notification.exception);
+    };
+
+    return {
+        init: function(root) {
+            root = $(root);
+            registerEventListeners(root);
+        }
+    };
+});
diff --git a/question/amd/src/repository.js b/question/amd/src/repository.js
new file mode 100644 (file)
index 0000000..42e7272
--- /dev/null
@@ -0,0 +1,48 @@
+// 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/>.
+
+/**
+ * A javascript module to handle question ajax actions.
+ *
+ * @module     core_question/repository
+ * @class      repository
+ * @package    core_question
+ * @copyright  2017 Simey Lameze <lameze@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+define(['jquery', 'core/ajax'], function($, Ajax) {
+
+    /**
+     * Submit the form data for the question tags form.
+     *
+     * @method submitTagCreateUpdateForm
+     * @param {string} formdata The URL encoded values from the form
+     * @return {promise}
+     */
+    var submitTagCreateUpdateForm = function(formdata) {
+        var request = {
+            methodname: 'core_question_submit_tags_form',
+            args: {
+                formdata: formdata
+            }
+        };
+
+        return Ajax.call([request])[0];
+    };
+
+    return {
+        submitTagCreateUpdateForm: submitTagCreateUpdateForm
+    };
+});
diff --git a/question/amd/src/selectors.js b/question/amd/src/selectors.js
new file mode 100644 (file)
index 0000000..438ecea
--- /dev/null
@@ -0,0 +1,34 @@
+// 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 purpose of this module is to centralize selectors related to question.
+ *
+ * @module     core_question/question_selectors
+ * @package    core_question
+ * @copyright  2018 Simey Lameze <lameze@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+define([], function() {
+    return {
+        actions: {
+            save: '[data-action="save"]',
+            edittags: '[data-action="edittags"]',
+        },
+        containers: {
+            loadingIcon: '[data-region="overlay-icon-container"]',
+        },
+    };
+});
index e4b1990..10f1544 100644 (file)
@@ -112,8 +112,8 @@ class question_category_list_item extends list_item {
         $item .= format_text($category->info, $category->infoformat,
                 array('context' => $this->parentlist->context, 'noclean' => true));
 
-        // don't allow delete if this is the last category in this context.
-        if (!question_is_only_toplevel_category_in_context($category->id)) {
+        // Don't allow delete if this is the top category, or the last editable category in this context.
+        if ($category->parent && !question_is_only_child_of_top_category_in_context($category->id)) {
             $deleteurl = new moodle_url($this->parentlist->pageurl, array('delete' => $this->id, 'sesskey' => sesskey()));
             $item .= html_writer::link($deleteurl,
                     $OUTPUT->pix_icon('t/delete', $str->delete),
@@ -295,17 +295,19 @@ class question_category_object {
 
     public function edit_single_category($categoryid) {
     /// Interface for adding a new category
-        global $COURSE, $DB;
+        global $DB;
         /// Interface for editing existing categories
-        if ($category = $DB->get_record("question_categories", array("id" => $categoryid))) {
-
+        $category = $DB->get_record("question_categories", array("id" => $categoryid));
+        if (empty($category)) {
+            print_error('invalidcategory', '', '', $categoryid);
+        } else if ($category->parent == 0) {
+            print_error('cannotedittopcat', 'question', '', $categoryid);
+        } else {
             $category->parent = "{$category->parent},{$category->contextid}";
             $category->submitbutton = get_string('savechanges');
             $category->categoryheader = $this->str->edit;
             $this->catform->set_data($category);
             $this->catform->display();
-        } else {
-            print_error('invalidcategory', '', '', $categoryid);
         }
     }
 
@@ -440,7 +442,7 @@ class question_category_object {
 
         // Get the record we are updating.
         $oldcat = $DB->get_record('question_categories', array('id' => $updateid));
-        $lastcategoryinthiscontext = question_is_only_toplevel_category_in_context($updateid);
+        $lastcategoryinthiscontext = question_is_only_child_of_top_category_in_context($updateid);
 
         if (!empty($newparent) && !$lastcategoryinthiscontext) {
             list($parentid, $tocontextid) = explode(',', $newparent);
index 93011aa..14ccf19 100644 (file)
@@ -38,7 +38,6 @@ require_once($CFG->libdir.'/formslib.php');
 class question_category_edit_form extends moodleform {
 
     protected function definition() {
-        global $CFG, $DB;
         $mform    = $this->_form;
 
         $contexts   = $this->_customdata['contexts'];
@@ -46,10 +45,10 @@ class question_category_edit_form extends moodleform {
 
         $mform->addElement('header', 'categoryheader', get_string('addcategory', 'question'));
 
-        $questioncategoryel = $mform->addElement('questioncategory', 'parent', get_string('parentcategory', 'question'),
-                    array('contexts'=>$contexts, 'top'=>true, 'currentcat'=>$currentcat, 'nochildrenof'=>$currentcat));
+        $mform->addElement('questioncategory', 'parent', get_string('parentcategory', 'question'),
+                array('contexts' => $contexts, 'top' => true, 'currentcat' => $currentcat, 'nochildrenof' => $currentcat));
         $mform->setType('parent', PARAM_SEQUENCE);
-        if (question_is_only_toplevel_category_in_context($currentcat)) {
+        if (question_is_only_child_of_top_category_in_context($currentcat)) {
             $mform->hardFreeze('parent');
         }
         $mform->addHelpButton('parent', 'parentcategory', 'question');
index abd7dc6..f37a8ef 100644 (file)
@@ -130,12 +130,11 @@ class category_condition extends condition {
      * @param string $current 'categoryID,contextID'.
      */
     protected function display_category_form($contexts, $pageurl, $current) {
-        global $OUTPUT;
-
         echo \html_writer::start_div('choosecategory');
-        $catmenu = question_category_options($contexts, false, 0, true);
+        $catmenu = question_category_options($contexts, true, 0, true);
         echo \html_writer::label(get_string('selectacategory', 'question'), 'id_selectacategory');
-        echo \html_writer::select($catmenu, 'category', $current, array(), array('class' => 'searchoptions custom-select', 'id' => 'id_selectacategory'));
+        echo \html_writer::select($catmenu, 'category', $current, array(),
+                array('class' => 'searchoptions custom-select', 'id' => 'id_selectacategory'));
         echo \html_writer::end_div() . "\n";
     }
 
diff --git a/question/classes/bank/tags_action_column.php b/question/classes/bank/tags_action_column.php
new file mode 100644 (file)
index 0000000..5f49808
--- /dev/null
@@ -0,0 +1,86 @@
+<?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 question tags column subclass.
+ *
+ * @package   core_question
+ * @copyright 2018 Simey Lameze <simey@moodle.com>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+namespace core_question\bank;
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Action to add and remove tags to questions.
+ *
+ * @package    core_question
+ * @copyright  2018 Simey Lameze <simey@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class tags_action_column extends action_column_base {
+
+    /**
+     * Return the name for this column.
+     *
+     * @return string
+     */
+    public function get_name() {
+        return 'tagsaction';
+    }
+
+    /**
+     * Display tags column content.
+     *
+     * @param object $question The question database record.
+     * @param string $rowclasses
+     */
+    protected function display_content($question, $rowclasses) {
+        global $DB;
+
+        if (\core_tag_tag::is_enabled('core_question', 'question') &&
+                question_has_capability_on($question, 'view')) {
+
+            $canedit = question_has_capability_on($question, 'edit');
+            $category = $DB->get_record('question_categories', ['id' => $question->category], 'contextid');
+            $url = $this->qbank->edit_question_url($question->id);
+
+            $this->print_tag_icon($question->id, $url, $canedit, $category->contextid);
+        }
+    }
+
+    /**
+     * Build and print the tags icon.
+     *
+     * @param int $id The question ID.
+     * @param string $url Editing question url.
+     * @param bool $canedit Whether the user can edit questions or not.
+     * @param int $contextid Question category context ID.
+     */
+    protected function print_tag_icon($id, $url, $canedit, $contextid) {
+        global $OUTPUT;
+
+        $params = [
+            'data-action' => 'edittags',
+            'data-canedit' => $canedit,
+            'data-contextid' => $contextid,
+            'data-questionid' => $id
+        ];
+
+        echo \html_writer::link($url, $OUTPUT->pix_icon('t/tags', get_string('managetags', 'tag')), $params);
+    }
+}
index 8c4142b..bc65791 100644 (file)
@@ -124,10 +124,9 @@ class view {
 
         if (empty($CFG->questionbankcolumns)) {
             $questionbankcolumns = array('checkbox_column', 'question_type_column',
-                                     'question_name_column', 'edit_action_column', 'copy_action_column',
-                                     'preview_action_column', 'delete_action_column',
-                                     'creator_name_column',
-                                     'modifier_name_column');
+                                     'question_name_column', 'tags_action_column', 'edit_action_column',
+                                     'copy_action_column', 'preview_action_column', 'delete_action_column',
+                                     'creator_name_column', 'modifier_name_column');
         } else {
              $questionbankcolumns = explode(',', $CFG->questionbankcolumns);
         }
@@ -481,6 +480,8 @@ class view {
                 $this->baseurl, $cat, $this->cm,
                 null, $page, $perpage, $showhidden, $showquestiontext,
                 $this->contexts->having_cap('moodle/question:add'));
+
+        $PAGE->requires->js_call_amd('core_question/edit_tags', 'init', ['#questionscontainer']);
     }
 
     protected function print_choose_category_message($categoryandcontext) {
@@ -702,7 +703,7 @@ class view {
         echo '<input type="hidden" name="sesskey" value="'.sesskey().'" />';
         echo \html_writer::input_hidden_params($this->baseurl);
 
-        echo '<div class="categoryquestionscontainer">';
+        echo '<div class="categoryquestionscontainer" id="questionscontainer">';
         $this->start_table();
         $rowcount = 0;
         foreach ($questions as $question) {
index dae3272..6210ff7 100644 (file)
@@ -114,4 +114,79 @@ class core_question_external extends external_api {
             )
         );
     }
+
+    /**
+     * Returns description of method parameters.
+     *
+     * @return external_function_parameters.
+     */
+    public static function submit_tags_form_parameters() {
+        return new external_function_parameters([
+                'formdata' => new external_value(PARAM_RAW, 'The data from the tag form'),
+        ]);
+    }
+
+    /**
+     * Handles the tags form submission.
+     *
+     * @param string $formdata The question tag form data in a URI encoded param string
+     * @return array The created or modified question tag
+     * @throws moodle_exception
+     */
+    public static function submit_tags_form($formdata) {
+        global $USER, $DB, $CFG;
+
+        $data = [];
+        $result = ['status' => false];
+
+        // Parameter validation.
+        $params = self::validate_parameters(self::submit_tags_form_parameters(), ['formdata' => $formdata]);
+        $context = \context_user::instance($USER->id);
+
+        self::validate_context($context);
+        parse_str($params['formdata'], $data);
+
+        if (!empty($data['id'])) {
+            $questionid = clean_param($data['id'], PARAM_INT);
+            $question = $DB->get_record('question', array('id' => $questionid));
+
+            require_once($CFG->libdir . '/questionlib.php');
+            $canedit = question_has_capability_on($question, 'edit');
+
+            require_once($CFG->dirroot . '/question/type/tags_form.php');
+            $mform = new \core_question\form\tags(null, null, 'post', '', null, $canedit, $data);
+
+            if ($validateddata = $mform->get_data()) {
+                // Due to a mform bug, if there's no tags set on the tag element, it submits the name as the value.
+                // The only way to discover is checking if the tag element is an array.
+                if ($canedit) {
+                    if (is_array($validateddata->tags)) {
+                        $categorycontext = context::instance_by_id($validateddata->contextid);
+
+                        core_tag_tag::set_item_tags('core_question', 'question', $validateddata->id,
+                            $categorycontext, $validateddata->tags);
+
+                        $result['status'] = true;
+                    } else {
+                        // If the tags element is not array, this means we don't have any tags to be set.
+                        // This is the only way to assume the user removed all tags from the question.
+                        core_tag_tag::remove_all_item_tags('core_question', 'question', $validateddata->id);
+
+                        $result['status'] = true;
+                    }
+                }
+            }
+        }
+
+        return $result;
+    }
+
+    /**
+     * Returns description of method result value.
+     */
+    public static function  submit_tags_form_returns() {
+        return new external_single_structure([
+                'status' => new external_value(PARAM_BOOL, 'status: true if success')
+        ]);
+    }
 }
index f2ca3ea..6fdb286 100644 (file)
@@ -95,28 +95,42 @@ function get_questions_category( $category, $noparent=false, $recurse=true, $exp
 }
 
 /**
+ * Checks whether this is the only child of a top category in a context.
+ *
  * @param int $categoryid a category id.
- * @return bool whether this is the only top-level category in a context.
+ * @return bool
  */
-function question_is_only_toplevel_category_in_context($categoryid) {
+function question_is_only_child_of_top_category_in_context($categoryid) {
     global $DB;
     return 1 == $DB->count_records_sql("
             SELECT count(*)
-              FROM {question_categories} c1,
-                   {question_categories} c2
-             WHERE c2.id = ?
-               AND c1.contextid = c2.contextid
-               AND c1.parent = 0 AND c2.parent = 0", array($categoryid));
+              FROM {question_categories} c
+              JOIN {question_categories} p ON c.parent = p.id
+              JOIN {question_categories} s ON s.parent = c.parent
+             WHERE c.id = ? AND p.parent = 0", array($categoryid));
+}
+
+/**
+ * Checks whether the category is a "Top" category (with no parent).
+ *
+ * @param int $categoryid a category id.
+ * @return bool
+ */
+function question_is_top_category($categoryid) {
+    global $DB;
+    return 0 == $DB->get_field('question_categories', 'parent', array('id' => $categoryid));
 }
 
 /**
- * Check whether this user is allowed to delete this category.
+ * Ensures that this user is allowed to delete this category.
  *
  * @param int $todelete a category id.
  */
 function question_can_delete_cat($todelete) {
     global $DB;
-    if (question_is_only_toplevel_category_in_context($todelete)) {
+    if (question_is_top_category($todelete)) {
+        print_error('cannotdeletetopcat', 'question');
+    } else if (question_is_only_child_of_top_category_in_context($todelete)) {
         print_error('cannotdeletecate', 'question');
     } else {
         $contextid = $DB->get_field('question_categories', 'contextid', array('id' => $todelete));
index 4f7ce0b..9528995 100644 (file)
@@ -69,7 +69,8 @@ class question_export_form extends moodleform {
         // Export options.
         $mform->addElement('header', 'general', get_string('general', 'form'));
 
-        $mform->addElement('questioncategory', 'category', get_string('exportcategory', 'question'), compact('contexts'));
+        $mform->addElement('questioncategory', 'category', get_string('exportcategory', 'question'),
+                array('contexts' => $contexts, 'top' => true));
         $mform->setDefault('category', $defaultcategory);
         $mform->addHelpButton('category', 'exportcategory', 'question');
 
index 20b92fe..78b5528 100644 (file)
@@ -499,6 +499,12 @@ class qformat_default {
             $contextid = false;
         }
 
+        // Before 3.5, question categories could be created at top level.
+        // From 3.5 onwards, all question categories should be a child of a special category called the "top" category.
+        if (isset($catnames[0]) && (($catnames[0] != 'top') || (count($catnames) < 3))) {
+            array_unshift($catnames, 'top');
+        }
+
         if ($this->contextfromfile && $contextid !== false) {
             $context = context::instance_by_id($contextid);
             require_capability('moodle/question:add', $context);
@@ -509,9 +515,15 @@ class qformat_default {
 
         // Now create any categories that need to be created.
         foreach ($catnames as $catname) {
-            if ($category = $DB->get_record('question_categories',
+            if ($parent == 0) {
+                $category = question_get_top_category($context->id, true);
+                $parent = $category->id;
+            } else if ($category = $DB->get_record('question_categories',
                     array('name' => $catname, 'contextid' => $context->id, 'parent' => $parent))) {
                 $parent = $category->id;
+            } else if ($parent == 0) {
+                $category = question_get_top_category($context->id, true);
+                $parent = $category->id;
             } else {
                 require_capability('moodle/question:managecategory', $context);
                 // create the new category
diff --git a/question/lib.php b/question/lib.php
new file mode 100644 (file)
index 0000000..5c04b33
--- /dev/null
@@ -0,0 +1,67 @@
+<?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/>.
+
+/**
+ * Question related functions.
+ *
+ * This file was created just because Fragment API expects callbacks to be defined on lib.php.
+ *
+ * Please, do not add new functions to this file.
+ *
+ * @package   core_question
+ * @copyright 2018 Simey Lameze <simey@moodle.com>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Question tags fragment callback.
+ *
+ * @param array $args Arguments to the form.
+ * @return null|string The rendered form.
+ */
+function core_question_output_fragment_tags_form($args) {
+
+    if (!empty($args['id'])) {
+        global $CFG, $DB;
+        require_once($CFG->dirroot . '/question/type/tags_form.php');
+        require_once($CFG->libdir . '/questionlib.php');
+        $id = clean_param($args['id'], PARAM_INT);
+
+        $question = $DB->get_record('question', ['id' => $id]);
+        $category = $DB->get_record('question_categories', array('id' => $question->category));
+        $context = \context::instance_by_id($category->contextid);
+
+        $toform = new stdClass();
+        $toform->id = $question->id;
+        $toform->questioncategory = $category->name;
+        $toform->questionname = $question->name;
+        $toform->categoryid = $category->id;
+        $toform->contextid = $category->contextid;
+        $toform->context = $context->get_context_name();
+
+        if (core_tag_tag::is_enabled('core_question', 'question')) {
+            $toform->tags = core_tag_tag::get_item_tags_array('core_question', 'question', $question->id);
+        }
+
+        $canedit = question_has_capability_on($question, 'edit');
+        $mform = new \core_question\form\tags(null, null, 'post', '', null, $canedit, $toform);
+        $mform->set_data($toform);
+
+        return $mform->render();
+    }
+}
index 5bccbb3..e0f344d 100644 (file)
@@ -46,4 +46,4 @@ Feature: A teacher can duplicate questions in the question bank
     When I click on "Duplicate" "link" in the "Test question to be copied" "table_row"
     And I press "Cancel"
     Then I should see "Test question to be copied"
-    And the field "Select a category" matches value "Test questions (1)"
+    And the field "Select a category" matches value "&nbsp;&nbsp;&nbsp;Test questions (1)"
index 138becb..8d66a27 100644 (file)
@@ -1,4 +1,4 @@
-@core @core_question
+@core @core_question @javascript
 Feature: A teacher can put questions in categories in the question bank
   In order to organize my questions
   As a teacher
@@ -16,9 +16,10 @@ Feature: A teacher can put questions in categories in the question bank
       | teacher1 | C1 | editingteacher |
     And the following "question categories" exist:
       | contextlevel | reference | questioncategory | name           |
-      | Course       | C1        | Top              | Default for C1 |
+      | Course       | C1        | Top              | top            |
+      | Course       | C1        | top              | Default for C1 |
       | Course       | C1        | Default for C1   | Subcategory    |
-      | Course       | C1        | Top              | Used category  |
+      | Course       | C1        | top              | Used category  |
     And the following "questions" exist:
       | questioncategory | qtype | name                      | questiontext                  |
       | Used category    | essay | Test question to be moved | Write about whatever you want |
@@ -38,7 +39,7 @@ Feature: A teacher can put questions in categories in the question bank
   Scenario: A question category can be edited
     When I navigate to "Categories" node in "Course administration > Question bank"
     And I click on "Edit" "link" in the "Subcategory" "list_item"
-    And the field "parent" matches value "   Default for C1"
+    And the field "parent" matches value "&nbsp;&nbsp;&nbsp;Default for C1"
     And I set the following fields to these values:
       | Name            | New name     |
       | Category info   | I was edited |
@@ -67,7 +68,7 @@ Feature: A teacher can put questions in categories in the question bank
     And I set the field "Question category" to "Subcategory"
     And I press "Move to >>"
     Then I should see "Test question to be moved"
-    And the field "Select a category" matches value "&nbsp;&nbsp;&nbsp;Subcategory (1)"
+    And the field "Select a category" matches value "&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;Subcategory (1)"
     And the "Select a category" select box should contain "Used category"
     And the "Select a category" select box should not contain "Used category (1)"
 
@@ -80,6 +81,6 @@ Feature: A teacher can put questions in categories in the question bank
     And I set the field "Save in category" to "Subcategory"
     And I press "id_submitbutton"
     Then I should see "Test question to be moved"
-    And the field "Select a category" matches value "&nbsp;&nbsp;&nbsp;Subcategory (1)"
+    And the field "Select a category" matches value "&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;Subcategory (1)"
     And the "Select a category" select box should contain "Used category"
     And the "Select a category" select box should not contain "Used category (1)"
index 0b8a0e5..21840f5 100644 (file)
@@ -47,15 +47,20 @@ class core_question_generator extends component_generator_base {
 
         $defaults = array(
             'name'       => 'Test question category ' . $this->categorycount,
-            'contextid'  => context_system::instance()->id,
             'info'       => '',
             'infoformat' => FORMAT_HTML,
             'stamp'      => make_unique_id_code(),
-            'parent'     => 0,
             'sortorder'  => 999,
         );
 
         $record = $this->datagenerator->combine_defaults_and_record($defaults, $record);
+
+        if (!isset($record['contextid'])) {
+            $record['contextid'] = context_system::instance()->id;
+        }
+        if (!isset($record['parent'])) {
+            $record['parent'] = question_get_top_category($record['contextid'], true)->id;
+        }
         $record['id'] = $DB->insert_record('question_categories', $record);
         return (object) $record;
     }
index f89d5bb..50f7ce9 100644 (file)
@@ -42,7 +42,9 @@ class core_question_generator_testcase extends advanced_testcase {
         $count = $DB->count_records('question_categories');
 
         $cat = $generator->create_question_category();
-        $this->assertEquals($count + 1, $DB->count_records('question_categories'));
+        $count += $count ? 1 : 2; // Calling $generator->create_question_category() for the first time
+                                  // creates a Top category as well.
+        $this->assertEquals($count, $DB->count_records('question_categories'));
 
         $cat = $generator->create_question_category(array(
                 'name' => 'My category', 'sortorder' => 1));
index 636c858..0ac8c14 100644 (file)
@@ -732,6 +732,12 @@ abstract class question_edit_form extends question_wizard_form {
             $errors['currentgrp'] = get_string('nopermissionmove', 'question');
         }
 
+        // Category.
+        if (empty($fromform['category'])) {
+            // User has provided an invalid category.
+            $errors['category'] = get_string('required');
+        }
+
         // Default mark.
         if (array_key_exists('defaultmark', $fromform) && $fromform['defaultmark'] < 0) {
             $errors['defaultmark'] = get_string('defaultmarkmustbepositive', 'question');
index 6f771a0..b2c2f26 100644 (file)
@@ -48,11 +48,14 @@ class qtype_random_edit_form extends question_edit_form {
         $mform->addElement('header', 'generalheader', get_string("general", 'form'));
 
         $mform->addElement('questioncategory', 'category', get_string('category', 'question'),
-                array('contexts' => $this->contexts->having_cap('moodle/question:useall')));
+                array('contexts' => $this->contexts->having_cap('moodle/question:useall'), 'top' => true));
 
         $mform->addElement('advcheckbox', 'questiontext[text]',
                 get_string('includingsubcategories', 'qtype_random'), null, null, array(0, 1));
 
+        $tops = question_get_top_categories_for_contexts(array_column($this->contexts->all(), 'id'));
+        $mform->hideIf('questiontext[text]', 'category', 'in', $tops);
+
         $mform->addElement('hidden', 'qtype');
         $mform->setType('qtype', PARAM_ALPHA);
 
index 0e15605..4c16d91 100644 (file)
@@ -29,6 +29,11 @@ $string['pluginname'] = 'Random';
 $string['pluginname_help'] = 'A random question is not a question type as such, but is a way of inserting a randomly-chosen question from a specified category into an activity.';
 $string['pluginnameediting'] = 'Editing a random question';
 $string['randomqname'] = 'Random ({$a})';
+$string['randomqnamefromtop'] = 'Faulty random question! Please delete this question.';
 $string['randomqplusname'] = 'Random ({$a} and subcategories)';
+$string['randomqplusnamecourse'] = 'Random (Any category in this course)';
+$string['randomqplusnamecoursecat'] = 'Random (Any category inside course category {$a})';
+$string['randomqplusnamemodule'] = 'Random (Any category of this quiz)';
+$string['randomqplusnamesystem'] = 'Random (Any system-level category)';
 $string['selectedby'] = '{$a->questionname} selected by {$a->randomname}';
 $string['selectmanualquestions'] = 'Random questions can use manually graded questions';
index 107e647..aecdaf6 100644 (file)
@@ -127,12 +127,36 @@ class qtype_random extends question_type {
      * @return string the name this question should have.
      */
     public function question_name($category, $includesubcategories) {
-        if ($includesubcategories) {
-            $string = 'randomqplusname';
+        if ($category->parent && $includesubcategories) {
+            $name = get_string('randomqplusname', 'qtype_random', shorten_text($category->name, 100));
+        } else if ($category->parent) {
+            $name = get_string('randomqname', 'qtype_random', shorten_text($category->name, 100));
+        } else if ($includesubcategories) {
+            $context = context::instance_by_id($category->contextid);
+
+            switch ($context->contextlevel) {
+                case CONTEXT_MODULE:
+                    $name = get_string('randomqplusnamemodule', 'qtype_random');
+                    break;
+                case CONTEXT_COURSE:
+                    $name = get_string('randomqplusnamecourse', 'qtype_random');
+                    break;
+                case CONTEXT_COURSECAT:
+                    $name = get_string('randomqplusnamecoursecat', 'qtype_random',
+                            shorten_text($context->get_context_name(false), 100));
+                    break;
+                case CONTEXT_SYSTEM:
+                    $name = get_string('randomqplusnamesystem', 'qtype_random');
+                    break;
+                default: // Impossible.
+                    $name = '';
+            }
         } else {
-            $string = 'randomqname';
+            // No question will ever be selected. So, let's warn the teacher.
+            $name = get_string('randomqnamefromtop', 'qtype_random');
         }
-        return get_string($string, 'qtype_random', shorten_text($category->name, 100));
+
+        return $name;
     }
 
     protected function set_selected_question_name($question, $randomname) {
@@ -143,11 +167,17 @@ class qtype_random extends question_type {
     }
 
     public function save_question($question, $form) {
+        global $DB;
+
         $form->name = '';
+        list($category) = explode(',', $form->category);
 
         // In case someone set the question text to true/false in the old style, set it properly.
         if ($form->questiontext['text']) {
             $form->questiontext['text'] = '1';
+        } else if ($DB->record_exists('question_categories', ['id' => $category, 'parent' => 0])) {
+            // The chosen category is a top category.
+            $form->questiontext['text'] = '1';
         } else {
             $form->questiontext['text'] = '0';
         }
diff --git a/question/type/tags_form.php b/question/type/tags_form.php
new file mode 100644 (file)
index 0000000..f2a59c6
--- /dev/null
@@ -0,0 +1,61 @@
+<?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 mform to manage question tags.
+ *
+ * @package   core_question
+ * @copyright 2018 Simey Lameze <simey@moodle.com>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core_question\form;
+
+defined('MOODLE_INTERNAL') || die();
+
+require_once($CFG->dirroot . '/lib/formslib.php');
+
+/**
+ * The mform class for  manage question tags.
+ *
+ * @copyright 2018 Simey Lameze <simey@moodle.com>
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class tags extends \moodleform {
+
+    /**
+     * The form definition
+     */
+    public function definition() {
+        $mform = $this->_form;
+
+        $mform->addElement('hidden', 'id');
+        $mform->setType('id', PARAM_INT);
+
+        $mform->addElement('hidden', 'categoryid');
+        $mform->setType('categoryid', PARAM_INT);
+
+        $mform->addElement('hidden', 'contextid');
+        $mform->setType('contextid', PARAM_INT);
+
+        $mform->addElement('static', 'questionname', get_string('questionname', 'question'));
+        $mform->addElement('static', 'questioncategory', get_string('categorycurrent', 'question'));
+        $mform->addElement('static', 'context', '');
+
+        $mform->addElement('tags', 'tags', get_string('tags'),
+                ['itemtype' => 'question', 'component' => 'core_question']);
+    }
+}
index a23abd8..7d1c68f 100644 (file)
@@ -252,13 +252,13 @@ fieldset.coursesearchbox label {
     margin: 0;
     padding: 0;
     border: 0;
-    margin-top: $font-size-base * $line-height-base + $tag-padding-y;
+    margin-top: $font-size-base * $line-height-base + $input-padding-y-sm;
     vertical-align: bottom;
 }
 .form-autocomplete-selection {
-    margin: $tag-padding-y;
+    margin: $input-padding-y-sm;
     // Padding top and bottom, plus m-b-1 and the 100% lineheight.
-    min-height: 2 * $tag-padding-y + 2 * $font-size-base;
+    min-height: 2 * $input-padding-y-sm + 2 * $font-size-base;
 }
 
 .form-autocomplete-multiple [role=listitem] {
index de780f8..eebf19c 100644 (file)
@@ -24,7 +24,7 @@ div[data-flexitour="step-background"] {
     @include border-radius($border-radius-lg);
 
     // The step container, and the target background should be at the same z-index.
-    z-index: #{$flexitour-base-zindex} + 1;
+    z-index: ($flexitour-base-zindex + 1);
 }
 
 span[data-flexitour="container"],
@@ -32,7 +32,7 @@ div[data-flexitour="step-background-fader"],
 [data-flexitour="step-backdrop"] > td,
 [data-flexitour="step-backdrop"] {
     // The step container, and the target background should be at the same z-index.
-    z-index: #{$flexitour-base-zindex} + 2;
+    z-index: ($flexitour-base-zindex + 2);
 }
 
 span[data-flexitour="container"] {
index 721e4bf..3ad7466 100644 (file)
@@ -42,7 +42,7 @@
                 echo '<td>'.$user->email.'</td><td>';
             }
             if (empty($user->email)) {
-                $error = get_string('emailempty');
+                $error = get_string('emailuserhasnone');
             }
             if (!empty($error)) {
                 echo $OUTPUT->pix_icon('t/emailno', $error);
index acdfe57..bb58a6f 100644 (file)
@@ -81,7 +81,8 @@ class profile_field_datetime extends profile_field_base {
         }
 
         if (is_numeric($datetime)) {
-            $datetime = userdate($datetime, '%Y-%m-%d-%H-%M-%S');
+            $gregoriancalendar = \core_calendar\type_factory::get_calendar_instance('gregorian');
+            $datetime = $gregoriancalendar->timestamp_to_date_string($datetime, '%Y-%m-%d-%H-%M-%S', 99, true, true);
         }
 
         $datetime = explode('-', $datetime);
index a882670..8ffb816 100644 (file)
 
 defined('MOODLE_INTERNAL') || die();
 
-$version  = 2018020100.00;              // YYYYMMDD      = weekly release date of this DEV branch.
+$version  = 2018020600.00;              // YYYYMMDD      = weekly release date of this DEV branch.
                                         //         RR    = release increments - 00 in DEV branches.
                                         //           .XX = incremental changes.
 
-$release  = '3.5dev (Build: 20180201)'; // Human-friendly version name
+$release  = '3.5dev (Build: 20180205)'; // Human-friendly version name
 
 $branch   = '35';                       // This version's branch.
 $maturity = MATURITY_ALPHA;             // This version's maturity level.