Friday, February 3, 2017

Adding search to your Moodle plugin - post mortem

Post Mortem - Production ready search

I ended up in a lot of discussions with other Moodle developers on the best way to implement the question content search. Originally, I created a separate search area, but was hampered by the fact that I couldn't reliably keep a record per question linked to a questionnaire instance.

The search API didn't allow me to create my own index other than an integer. If it would have allowed me to construct my own, I could have stored information that identified the questionnaire and question. This resulted in the creation of the Moodle tracker item MDL-57857.

However, as we discussed this particular problem more, it really seemed that if I was searching for questionnaire instances that contained the searched for question content, then the index should really be part of the activity search. This resulted in the update I posted at the end of the last post. While that worked, the return results displayed weren't very attractive and, as was pointed out, would also include question content that may never have been displayed to the use. Questionnaires can choose questions to display based on answers to other questions.

At that point, I realized that the most likely use case for searching for question content would be for users who can access all of the question content of a questionnaire. This would be users who could actually edit the questions and users who could see all of the responses to any question.

So, it made more sense to again separate out the question search area into its own search area. This allows for two important things:

  1. Allows the administrator of the site to determine if they want to even allow question content searching.
  2. Allows the access for this content to be limited to users who have the ability to see all of the question content for a questionnaire.
You can see the final code I decided on here. The question area still stores one record per questionnaire activity, like the activity area, but it uses a different set of access rules. And, since it is a separate area, it can be enabled/disabled globally, and filtered on separately.

I believe that until there is a way to store information in the search index that allows me to go directly to the question within a specific questionnaire, this is the best solution.

Wednesday, February 1, 2017

Adding search to your plugin - Part three

Part Three - Indexing More of your Plugin

In the last post, I added more searchable content to my plugin's search indexing function, so that additional information fields specific to each instance of a plugin were included. Now I'm going to try and add question content to the search indexing.

The documentation tells me that if I'm going to index other activity data, I should use the base_mod class instead of the base_activity class. The base_mod class is located in the core file /search/classes/base_mod.php. Looking at that class, I see that it only contains one method: get_cm. All the rest come from the class it extends, base. The functions I used before, get_recordset_by_timestamp() and get_document() are abstract functions of base. This means I must provide their implementations. I also note that three other abstract functions, check_access(), get_doc_url() and get_context_url() that I will likewise need to provide implementations for. In my previous work, these three functions were provided to me by the base_activity class.

Back to the documentation, the first step it points out is that if I am going to add a new search area for my plugin, I need to provide a name for it in my language strings. Since I am going to index the questions, I add the string:

    $string['search:question'] = 'Questionnaire - questions';

to my lang/en/questionnaire.php file.

Next, I need to provide the indexed content from my questions to the search engine, using the get_recordset_by_timestamp() function. Recall that this function is responsible to construct the necessary database query for all of the content I want available for searching, execute that query, and then return the resulting recordset. For this, I create a new file called 'classes/search/question.php' and create a new question class extending the \core_search\base_mod class.

For questionnaire questions, I will need to join two tables. Question data is contained in a table that contains a reference to a survey id. That survey id is also referenced in the main questionnaire table. Since question data is not accessed outside of a survey context, I will need to create SQL that returns each question with an associated questionnaire and survey.

The function I create looks like this (full file here):

    public function get_recordset_by_timestamp($modifiedfrom = 0) {
        global $DB;

        $sql = 'SELECT qq.id, q.id AS questionnaireid, q.timemodified, ' .
            'q.course AS courseid, q.introformat, qq.name, qq.content ' .
            'FROM {questionnaire} q ' .
            'INNER JOIN {questionnaire_question} qq ON q.sid = qq.survey_id AND ' .
            'qq.deleted = \'n\' ' .
            'WHERE q.timemodified >= ? ' .
            'ORDER BY q.timemodified ASC';

        return $DB->get_recordset_sql($sql, [$modifiedfrom]);
    }

Note that I use the question id field, 'qq.id', as the first field. Moodle requires the first field of a query return to be a unique field that is used as an index for the resulting data array. Since there will be multiple questions per questionnaire and survey record, I can't use those. I will need the questionnaire id field though, so I rename it to 'questionnaireid' in the returned data. The question name and content fields, 'qq.name' and 'qq.content', contain the data that will actually be searched. This function will provide the data that will be indexed by the search function.

Now I need to provide the get_document() function. This is the function that looks at an indexed record returned from a search query and constructs the document object that is displayed on the search results screen. A document object contains a 'title' and 'content' field. I set the question 'name' data to the document 'title' and the question 'content' data to the document 'content'. The rest of the object is pretty much boilerplate from the base_activity class code and the documentation.

The function I create looks like this (full file here):

    public function get_document($record, $options = []) {
        try {
            $cm = $this->get_cm('questionnaire', $record->questionnaireid,
                $record->courseid);
            $context = \context_module::instance($cm->id);
        } catch (\dml_missing_record_exception $ex) {
            // Notify it as we run here as admin, we should see everything.
            debugging('Error retrieving ' . $this->areaid . ' ' . $record->id .
                ' document, not all required data is available: ' .
                $ex->getMessage(), DEBUG_DEVELOPER);
            return false;
        } catch (\dml_exception $ex) {
            // Notify it as we run here as admin, we should see everything.
            debugging('Error retrieving ' . $this->areaid . ' ' . $record->id .
                ' document: ' . $ex->getMessage(), DEBUG_DEVELOPER);
            return false;
        }

        // Prepare associative array with data from DB.
        $doc = \core_search\document_factory::instance($record->id, $this->componentname,
            $this->areaname);
        $doc->set('title', content_to_text($record->name, false));
        $doc->set('content', content_to_text($record->content, $record->introformat));
        $doc->set('contextid', $context->id);
        $doc->set('courseid', $record->courseid);
        $doc->set('owneruserid', \core_search\manager::NO_OWNER_ID);
        $doc->set('modified', $record->timemodified);

        return $doc;
    }

Prior to constructing the returned document object, the search API needs to verify that the requesting user has the right permissions to access the Moodle item. This is done with check_access() function. Generally speaking, what this function should do is to extract the Moodle item in the correct Moodle context, and then check that item and context with the requesting user's capabilities. In my case, a question's data is tied to the questionnaire it belongs to. So determining if a user can see that is based on their access to the specific questionnaire instance.

For questionnaire, this is complicated by the fact that a survey (which contains the questions) can belong to more than one questionnaire (in the case of public questionnaires). While this is an uncommon use, it can still exist. The check_access() function only provides me with the unique id of the data that was saved. For questions, this is the question id field. This means that I do not know which questionnaire instance I need to check when I receive this id data. So my function will need to "guess" at which questionnaire to return by looking for one that contains the question data that is accessible to the user.

The function I create looks like this (full file here):

    public function check_access($id) {
        global $DB;

        try {
            // Questions are in surveys and surveys can be used in multiple questionnaires.
            $question = $DB->get_record('questionnaire_question', ['id' => $id],
                '*', MUST_EXIST);
            $questionnaires = $DB->get_records('questionnaire',
                ['sid' => $question->survey_id]);
            if (empty($questionnaires)) {
                return \core_search\manager::ACCESS_DELETED;
            }
            $cmsinfo = [];
            foreach ($questionnaires as $questionnaire) {
                $cmsinfo[] = $this->get_cm('questionnaire', $questionnaire->id,
                    $questionnaire->course);
            }
        } catch (\dml_missing_record_exception $ex) {
            return \core_search\manager::ACCESS_DELETED;
        } catch (\dml_exception $ex) {
            return \core_search\manager::ACCESS_DENIED;
        }

        // Recheck uservisible although it should have already been checked in core_search.
        // Use the first one that is visible. Otherwise exit.
        $cmvisible = false;
        foreach ($cmsinfo as $cminfo) {
            if ($cminfo->uservisible !== false) {
                $cmvisible = $cminfo;
                break;
            }
        }
        if ($cmvisible === false) {
            return \core_search\manager::ACCESS_DENIED;
        }

        $context = \context_module::instance($cminfo->id);

        if (!has_capability('mod/questionnaire:view', $context)) {
            return \core_search\manager::ACCESS_DENIED;
        }

        return \core_search\manager::ACCESS_GRANTED;
    }

Without going into too much detail, the basic function is:
  • find the question data and all questionnaires that might contain it,
  • get a valid course module for each questionnaire,
  • find the first course module that is visible to the user and assume this is the one,
  • check that the user has the proper capabilities to view that questionnaire.
For this post, the functionality will suffice to show how this function works. Unless the function finds reason not to grant access, then granted access is returned.

(Note - I may be able to modify my functions to return a search id field that I construct to identify both the question and the questionnaire. I will look into this later, as I am not happy with "guessing" which questionnaire is used.)

The last two functions I need to provide are get_doc_url() and get_context_url(). These functions provide the link to the resulting page that will display the found content in its proper Moodle context. Since there is no way to look at only a question instance, my functions will provide a link to the questionnaire. Both functions will provide the same link. I write the code for the get_context_url() and just call that from the get_doc_url() function.

The get_context_url code looks like this (full file here):

    public function get_context_url(\core_search\document $doc) {
        $context = \context::instance_by_id($doc->get('contextid'));
        return new \moodle_url('/mod/questionnaire/view.php',
            ['id' => $context->instanceid]);
    }

Pretty straightforward.

Since I have everything I need, I load the new code, reindex all of the site contents and give it a test.

One of the first things I notice is that there is a new "search area" in the dropdown called "Questionnaire - questions":


Next, I search for a term that I know is only in some questions. Sure enough, I see the question name and the question content where matches occur. And clicking the link takes me to the appropriate questionnaire.

Updating your plugin to use the new global search feature is a fairly easy process, especially if all you do is provide the basic search. There should be no reason not to do it!

This concludes my series on adding global search to your Moodle plugin. Feel free to leave comments and suggestions. I will look at improving the question results as I noted above, and will provide updates when I have completed that.

Checklist for building additional plugin content searching:
  1. Create a new class with the name of your area in a similarly named file in your 'classes/search' directory.
  2. Add a language string name using "search:AREANAME" for the search area in your plugin language file.
  3. Create a get_recordset_by_timestamp() function to retrieve the records you want to index.
  4. Create a get_document() function to return a completed document object to the search function.
  5. Create a check_access() function to determine a matched document's availability to the user performing the search.
  6. Create a get_context_url() function to return an appropriate Moodle URL to the matched document.
  7. Create a get_doc_url() function to return an appropriate Moodle URL to the matched document.

Update - 2017.02.02
After a lot of discussion with other developers, I redid the activity search to include the question content. Since the question content was helping to find the questionnaire instance that contained it, it really made sense for it to be a part of the activity search and instance return. Since there could be many question records per questionnaire instance, I had to do the database search in the get_document() function and add the question data there. You can see the resulting work here.