Using the question engine from module
- Goals
- Rationale
- How it currently works
- New system overview
- Detailed design
- Question Engine 2 Developer docs:
- Implementation plan
- Testing
This page explains how to use the new Moodle question engine in an activity module you are developing.
Previous section: Developing a Question Type
The first example of a module that uses the question engine is the quiz module. Looking at how the quiz code works will let you see a real working example of what is explained on this page. Another, much simpler example to look at is question/preview.php, and https://github.com/moodleou/moodle-filter_embedquestion/blob/master/showquestion.php is simpler still.
Note that all the question engine code has extensive PHP documentor comments that should explain the purpose of every class and method. This document is supposed to provide an overview of the key points to get you started. It does not attempt to duplicate all the details in the PHPdocs.
Overview
The key class you will be working with is question_usage_by_activity. This tracks an attempt at some questions, for example a quiz attempt. The usage should provide all the interface methods you need to do things with the question engine. You should rarely need to dive deeper into the inner workings of the question engine than this.
A usage is a collection of question_attempt objects. A question attempt represents the state of the user's attempt at one question within the usage. When you add a question to the usage, you get back a slot number. This serves to index that question within the attempt. Using slot numbers like this means that the same question can be added more than once to the same usage. A lot of the usage API calls take this slot number to indicate which question attempt to operate on. Other API methods let you perform batch actions on all the question attempts within the usage.
Starting an attempt at some questions
The example code in this section all came from mod/quiz/startattempt.php.
Creating the usage
To create a new usage, ask the question engine:
$quba = question_engine::make_questions_usage_by_activity('mod_quiz', $quizobj->get_context());
$quba->set_preferred_behaviour($quizobj->get_quiz()->preferredbehaviour);
You will see that the constructor takes the plugin name (using the Moodle 2.0 'frankenstyle' naming convention) and the context that owns the usage. Usages should be cleaned up automatically when a context is deleted or a plugin un-installed. You also have to tell the usage which behaviour should be used when creating the attempt at each question.
Adding questions
Having got a usage, you will probably then want to add some questions to it. The question data you loaded from the database (probably using the get_question_options function) come in a form suitable for data handling like import/export, backup/restore, and so on. We need to convert that to an question_definition object. That can be done using the question_bank::make_question method. Alternatively, you could just use the question_bank::load_question method, but that can only load one question at a time.
Once you have a question_definition object, you add it to the usage by calling the add_question method passing the question definition, and also the score you want this question marked out of. You get back the slot number that has been assigned to this question. The slot numbers are allocated in order, starting from 1. You can rely on that numbering convention. For example, the quiz module uses the slot numbers to make sure the grade for each question lines up properly in the reports.
Here is the applicable code from the quiz module (some code to handle random questions and question shuffling omitted)
$slot = 0;
$questions = [];
$maxmark = [];
$page = [];
foreach ($quizobj->get_questions() as $questiondata) {
$slot += 1;
$maxmark[$slot] = $questiondata->maxmark;
$page[$slot] = $questiondata->page;
$questions[$slot] = question_bank::make_question($questiondata);
}
Starting the question attempts
The usage does not do much when the question is added. Before the user can start interacting with the question, you have to start it. You can either do that in bulk:
$quba->start_all_questions();
or you can start just one particular question using the start_question($slot) method.
Saving the usage
So far, all these method calls have just built up a data structure in memory. If you want to keep it, you have to save it in the database. Fortunately, that is easy:
question_engine::save_questions_usage_by_activity($quba);
The usage is stored as a row in the question_usages table (plus rows in other tables). To get the id of the usage, call $quba->get_id() after you have saved it.
Displaying questions
Suppose that you wish to display a page that contains several questions from the usage. The questions to display will be the ones whose slot numbers are in an array called $slotsonpage.
The example code in this section is derived from code used by mod/quiz/attempt.php. However, the quiz code is split between mod/quiz/attempt.php and the quiz_attempt class in mod/quiz/attemptlib.php. That separation means that if I used the real quiz code in this tutorial, it would be unnecessarily hard to understand. Instead, I have simplified the examples below. If you are interested, you should be able to match up what you see below with the real quiz code.
Loading the usage
At the end of the last section, you had just saved the usage to the database. To display the questions as part of a different script, they must be loaded from the database again. Once again you ask the question engine to do this:
$quba = question_engine::load_questions_usage_by_activity($usageid);
The id that you pass to this method is the id you got from $quba->get_id() after saving the usage.
The question form
Within the page, all the questions have to be wrapped in a HTML <form> tag. This form needs to POST to a scripts that processes the submitted data (see below).
echo '<form id="responseform" method="post" action="' . $processurl .
'" enctype="multipart/form-data" accept-charset="utf-8">', "\n<div>\n";
$PAGE->requires->js_init_call('M.core_question_engine.init_form',
array('#responseform'), false, 'core_question_engine');
echo '<input type="hidden" name="slots" value="' . implode(',', $slotsonpage) . "\" />\n";
echo '<input type="hidden" name="scrollpos" value="" />';
The enctype and accept-charset parameters are important. Please copy them. The form must have an id, and you must then write some JavaScript that passes that id to to the question_init_form function. That function is defined in question/qengine.js if you want to find out what it does and why it is important.
You should output a hidden form field slots that contains a comma-separated list of the slot numbers that appear on this page. That is not absolutely necessary, but it will make processing the form submission much more efficient.
Another nicety is the scrollpos hidden field. Some question behaviours, for example the interactive behaviour, have submit buttons that do something to the question. When the user clicks this button, it is nice if when the page re-displays, it is scrolled to exactly the same place where it was. The question engine has JavaScript which, if it finds a scrollpos hidden field, will automatically save the scroll position to it when a button is clicked, and cause the page to scroll to that position when it reloads.
Outputting the questions
Finally, you can actually print the questions. Well, there is one more thing to take care of first. To control how the question appears, you must prepare a question_display_options object that describes what should be visible. For example:
$options = new question_display_options();
$options->marks = question_display_options::MAX_ONLY;
$options->markdp = 2; // Display marks to 2 decimal places.
$options->feedback = question_display_options::VISIBLE;
$options->generalfeedback = question_display_options::HIDDEN;
// etc.
(In the quiz code, the display options actually come from the quiz settings.) Then you really are ready to output the questions
foreach ($slotsonpage as $displaynumber => $slot) {
echo $quba->render_question($slot, $options, $displaynumber);
}
Here $displaynumber is the 'question number' you want printed next to each question. This is not necessarily the same as the slot number. For example, in the quiz, Description 'questions' are not numbered, although they take up a slot. It does not have to be a single integer and can also be combined with alphabetic glyphs if you have some other scheme for identifying questions in your activity. For example you could pass '4.3.1', '2a)' '2b)', or '7 of 10' if that is what your activity needs. If you don't pass in a number to display, that is, if you pass null, then the question is printed without a Question X heading.
Processing responses
Once again, the example here are inspired by real code from the quiz module, in this case mod/quiz/processattempt.php, but simplified to remove mention of the quiz_attempt class.
The simple case
Normally, you just need to do something like:
$timenow = time();
$transaction = $DB->start_delegated_transaction();
$quba = question_engine::load_questions_usage_by_activity($usageid);
$quba->process_all_actions($timenow);
question_engine::save_questions_usage_by_activity($quba);
$transaction->allow_commit();
Behind the scenes there is, of course, a lot going on, including the use of the slots parameter to control which questions are processed.
There may also be additional work you need to do. For example, in the quiz (before the commit_sql() line) there is:
if ($attempt->timefinish) {
$attempt->sumgrades = $quba->get_total_mark();
}
$attempt->timemodified = $timenow;
$DB->update_record('quiz_attempts', $attempt);
More selective processing
If you want something other than the default processing, you can use code like $submitteddata = $quba->extract_responses($slot); $quba->process_action($slot, $submitteddata); or something similar.
Handling scrollpos
You will remember that when we printed the questions, we created a scrollpos hidden field. Presumably, after you have processed the submission, you will redirect the user back to a page to display the questions again. In that case, you need to get the scrollpos parameter, and add it on to the URL you redirect to:
// With the other required/optional_param calls at the start of processattempt.php.
$scrollpos = optional_param('scrollpos', '', PARAM_RAW);
// ... a bit later in the file
$nexturl = new moodle_url(/* something */);
if ($scrollpos !== '') {
$nexturl->param('scrollpos', (int) $scrollpos);
}
// ... after all the processing is done.
redirect($nexturl);
Reports
Places like the quiz reports, we need to get data about a lot of usages at the same time. There are methods on the question_engine_data_mapper to do this. You should never access the data in the question engine tables directly.
For example from the quiz grades report. (Again the example code has been simplified.)
// $qubaids is an array of integer ids.
$qubaidscondition = new qubaid_list($qubaids);
$dm = new question_engine_data_mapper();
$latesstepdata = $dm->load_questions_usages_latest_steps(
$qubaidscondition, $slots);
qubaid_list is a subclass of the qubaid_condition class. This is an efficient way to represent a set of usage ids in a way that can be used in database queries. The other main subclass is qubaid_join. That could be used like this:
$qubaidscondition = new qubaid_join('{quiz_attempts} quiza', 'quiza.uniqueid',
'quiza.quiz = :quizaquiz', array('quizaquiz' => $quizid))
Another example, from the quiz manual grading report is
$dm = new question_engine_data_mapper();
$statesummary = $dm->load_questions_usages_question_state_summary(
$qubaidscondition, $slots);
(Perhaps the use of the class question_engine_data_mapper should be hidden inside a helper class question_engine_reporter?)
Other methods
Methods to help with settings UI
When you want to present users with a choice of question behaviour (for example, the quiz How questions behave option) use
$options = question_engine::get_behaviour_options();
Question marks are stored in the database to a fixed number of decimal places. To get a list of possible decimal place display options, use
$options = question_engine::get_dp_options();
Clean-up methods
To delete usages you no longer need:
// A single usage ...
question_engine::delete_questions_usage_by_activity($qubaid);
// ... or many.
question_engine::delete_questions_usage_by_activities(
new qubaid_join('{quiz_attempts} quiza', 'quiza.uniqueid',
'quiza.quiz = :quizaquiz', array('quizaquiz' => $quizid)));
Changing the score of a question in all attempts
Suppose you change the number of marks awarded to question 2 in the quiz, you need to change that information in every attempt at that quiz, you can do that with a call like
question_engine::set_max_mark_in_attempts($qubaids, $slot, $newmaxmark);
Re-grading a question
You only need to re-grade a question when the question definition is edited, for example to change the scoring rules. If you are just changing the maximum possible score for a question, see the previous sub-section.
This code example is adapted from mod/quiz/report/overview/report.php. It re-grades selected slots within a quiz attempt.
$transaction = $DB->start_delegated_transaction();
$quba = question_engine::load_questions_usage_by_activity($attempt->uniqueid);
foreach ($slots as $slot) {
$oldfraction[$slot] = $quba->get_question_fraction($slot);
$quba->regrade_question($slot);
$newfraction[$slot] = $quba->get_question_fraction($slot);
}
question_engine::save_questions_usage_by_activity($quba);
$attempt->sumgrades = $quba->get_total_mark();
update_record('quiz_attempts', $attempt);
$transaction->allow_commit();
See also
In the next section, Implementation plan outlines how I will implement this proposal.
- https://github.com/moodleou/moodle-filter_embedquestion/blob/master/showquestion.php is probably the simplest example code that displays a question and lets a user interact with it.
- The PHP documenter comments that explain the purposes of every method in the question engine code.
- Back to Question Engine 2