From 2adefc21acddbbb1cbcd9983f522d46b6c06cfad Mon Sep 17 00:00:00 2001
From: Shamim Rezaie <shamim@moodle.com>
Date: Sun, 18 Feb 2018 06:36:14 +1100
Subject: [PATCH] MDL-61380 Questions: Support selecting "random" by tag in
 question bank

---
 .../classes/bank/random_question_loader.php   | 45 +++++++----
 question/engine/bank.php                      | 76 ++++++++++++++-----
 2 files changed, 87 insertions(+), 34 deletions(-)

diff --git a/question/classes/bank/random_question_loader.php b/question/classes/bank/random_question_loader.php
index d897c81607e..f1b1578e2f9 100644
--- a/question/classes/bank/random_question_loader.php
+++ b/question/classes/bank/random_question_loader.php
@@ -77,6 +77,7 @@ class random_question_loader {
 
     /**
      * Pick a question at random from the given category, from among those with the fewest uses.
+     * If an array of tag ids are specified, then only the questions that are tagged with ALL those tags will be selected.
      *
      * It is up the the caller to verify that the cateogry exists. An unknown category
      * behaves like an empty one.
@@ -84,12 +85,14 @@ class random_question_loader {
      * @param int $categoryid the id of a category in the question bank.
      * @param bool $includesubcategories wether to pick a question from exactly
      *      that category, or that category and subcategories.
+     * @param array $tagids An array of tag ids. A question has to be tagged with all the provided tagids (if any)
+     *      in order to be eligible for being picked.
      * @return int|null the id of the question picked, or null if there aren't any.
      */
-    public function get_next_question_id($categoryid, $includesubcategories) {
-        $this->ensure_questions_for_category_loaded($categoryid, $includesubcategories);
+    public function get_next_question_id($categoryid, $includesubcategories, $tagids = []) {
+        $this->ensure_questions_for_category_loaded($categoryid, $includesubcategories, $tagids);
 
-        $categorykey = $this->get_category_key($categoryid, $includesubcategories);
+        $categorykey = $this->get_category_key($categoryid, $includesubcategories, $tagids);
         if (empty($this->availablequestionscache[$categorykey])) {
             return null;
         }
@@ -107,26 +110,35 @@ class random_question_loader {
      * @param int $categoryid the id of a category in the question bank.
      * @param bool $includesubcategories wether to pick a question from exactly
      *      that category, or that category and subcategories.
+     * @param array $tagids an array of tag ids.
      * @return string the cache key.
      */
-    protected function get_category_key($categoryid, $includesubcategories) {
+    protected function get_category_key($categoryid, $includesubcategories, $tagids = []) {
         if ($includesubcategories) {
-            return $categoryid . '|1';
+            $key = $categoryid . '|1';
         } else {
-            return $categoryid . '|0';
+            $key = $categoryid . '|0';
         }
+
+        if (!empty($tagids)) {
+            $key .= '|' . implode('|', $tagids);
+        }
+
+        return $key;
     }
 
     /**
      * Populate {@link $availablequestionscache} for this combination of options.
-     * @param int $categoryid the id of a category in the question bank.
-     * @param bool $includesubcategories wether to pick a question from exactly
+     * @param int $categoryid The id of a category in the question bank.
+     * @param bool $includesubcategories Whether to pick a question from exactly
      *      that category, or that category and subcategories.
+     * @param array $tagids An array of tag ids. If an array is provided, then
+     *      only the questions that are tagged with ALL the provided tagids will be loaded.
      */
-    protected function ensure_questions_for_category_loaded($categoryid, $includesubcategories) {
+    protected function ensure_questions_for_category_loaded($categoryid, $includesubcategories, $tagids = []) {
         global $DB;
 
-        $categorykey = $this->get_category_key($categoryid, $includesubcategories);
+        $categorykey = $this->get_category_key($categoryid, $includesubcategories, $tagids);
 
         if (isset($this->availablequestionscache[$categorykey])) {
             // Data is already in the cache, nothing to do.
@@ -143,8 +155,8 @@ class random_question_loader {
         list($extraconditions, $extraparams) = $DB->get_in_or_equal($this->excludedqtypes,
                 SQL_PARAMS_NAMED, 'excludedqtype', false);
 
-        $questionidsandcounts = \question_bank::get_finder()->get_questions_from_categories_with_usage_counts(
-                $categoryids, $this->qubaids, 'q.qtype ' . $extraconditions, $extraparams);
+        $questionidsandcounts = \question_bank::get_finder()->get_questions_from_categories_and_tags_with_usage_counts(
+                $categoryids, $this->qubaids, 'q.qtype ' . $extraconditions, $extraparams, $tagids);
         if (!$questionidsandcounts) {
             // No questions in this category.
             $this->availablequestionscache[$categorykey] = array();
@@ -200,16 +212,19 @@ class random_question_loader {
 
     /**
      * Check whether a given question is available in a given category. If so, mark it used.
+     * If an optional list of tag ids are provided, then the question must be tagged with
+     * ALL of the provided tags to be considered as available.
      *
      * @param int $categoryid the id of a category in the question bank.
      * @param bool $includesubcategories wether to pick a question from exactly
      *      that category, or that category and subcategories.
      * @param int $questionid the question that is being used.
+     * @param array $tagids An array of tag ids. Only the questions that are tagged with all the provided tagids can be available.
      * @return bool whether the question is available in the requested category.
      */
-    public function is_question_available($categoryid, $includesubcategories, $questionid) {
-        $this->ensure_questions_for_category_loaded($categoryid, $includesubcategories);
-        $categorykey = $this->get_category_key($categoryid, $includesubcategories);
+    public function is_question_available($categoryid, $includesubcategories, $questionid, $tagids = []) {
+        $this->ensure_questions_for_category_loaded($categoryid, $includesubcategories, $tagids);
+        $categorykey = $this->get_category_key($categoryid, $includesubcategories, $tagids);
 
         foreach ($this->availablequestionscache[$categorykey] as $questionids) {
             if (isset($questionids[$questionid])) {
diff --git a/question/engine/bank.php b/question/engine/bank.php
index b5fb62c9412..6721cbfc05b 100644
--- a/question/engine/bank.php
+++ b/question/engine/bank.php
@@ -294,10 +294,6 @@ abstract class question_bank {
      */
     public static function get_finder() {
         return question_finder::get_instance();
-        if (is_null(self::$questionfinder)) {
-            self::$questionfinder = new question_finder();
-        }
-        return self::$questionfinder;
     }
 
     /**
@@ -539,29 +535,71 @@ class question_finder implements cache_data_source {
      */
     public function get_questions_from_categories_with_usage_counts($categoryids,
             qubaid_condition $qubaids, $extraconditions = '', $extraparams = array()) {
+        return $this->get_questions_from_categories_and_tags_with_usage_counts(
+                $categoryids, $qubaids, $extraconditions, $extraparams);
+    }
+
+    /**
+     * Get the ids of all the questions in a list of categories that have ALL the provided tags,
+     * with the number of times they have already been used in a given set of usages.
+     *
+     * The result array is returned in order of increasing (count previous uses).
+     *
+     * @param array $categoryids an array of question_category ids.
+     * @param qubaid_condition $qubaids which question_usages to count previous uses from.
+     * @param string $extraconditions extra conditions to AND with the rest of
+     *      the where clause. Must use named parameters.
+     * @param array $extraparams any parameters used by $extraconditions.
+     * @param array $tagids an array of tag ids
+     * @return array questionid => count of number of previous uses.
+     */
+    public function get_questions_from_categories_and_tags_with_usage_counts($categoryids,
+            qubaid_condition $qubaids, $extraconditions = '', $extraparams = array(), $tagids = array()) {
         global $DB;
 
         list($qcsql, $qcparams) = $DB->get_in_or_equal($categoryids, SQL_PARAMS_NAMED, 'qc');
 
+        $select = "q.id, (SELECT COUNT(1)
+                            FROM " . $qubaids->from_question_attempts('qa') . "
+                           WHERE qa.questionid = q.id AND " . $qubaids->where() . "
+                         ) AS previous_attempts";
+        $from   = "{question} q";
+        $where  = "q.category {$qcsql}
+               AND q.parent = 0
+               AND q.hidden = 0";
+        $params = $qcparams;
+
+        if (!empty($tagids)) {
+            // We treat each additional tag as an AND condition rather than
+            // an OR condition.
+            //
+            // For example, if the user filters by the tags "foo" and "bar" then
+            // we reduce the question list to questions that are tagged with both
+            // "foo" AND "bar". Any question that does not have ALL of the specified
+            // tags will be omitted.
+            list($tagsql, $tagparams) = $DB->get_in_or_equal($tagids, SQL_PARAMS_NAMED, 'ti');
+            $tagparams['tagcount'] = count($tagids);
+            $tagparams['questionitemtype'] = 'question';
+            $tagparams['questioncomponent'] = 'core_question';
+            $where .= " AND q.id IN (SELECT ti.itemid
+                                       FROM {tag_instance} ti
+                                      WHERE ti.itemtype = :questionitemtype
+                                            AND ti.component = :questioncomponent
+                                            AND ti.tagid {$tagsql}
+                                   GROUP BY ti.itemid
+                                     HAVING COUNT(itemid) = :tagcount)";
+            $params += $tagparams;
+        }
+
         if ($extraconditions) {
             $extraconditions = ' AND (' . $extraconditions . ')';
         }
 
-        return $DB->get_records_sql_menu("
-                    SELECT q.id, (SELECT COUNT(1)
-                                    FROM " . $qubaids->from_question_attempts('qa') . "
-                                   WHERE qa.questionid = q.id AND " . $qubaids->where() . "
-                                 ) AS previous_attempts
-
-                      FROM {question} q
-
-                     WHERE q.category {$qcsql}
-                       AND q.parent = 0
-                       AND q.hidden = 0
-                      {$extraconditions}
-
-                  ORDER BY previous_attempts
-                ", $qubaids->from_where_params() + $qcparams + $extraparams);
+        return $DB->get_records_sql_menu("SELECT $select
+                                            FROM $from
+                                           WHERE $where $extraconditions
+                                        ORDER BY previous_attempts",
+                $qubaids->from_where_params() + $params + $extraparams);
     }
 
     /* See cache_data_source::load_for_cache. */