- Goals
- Rationale
- How it currently works
- New system overview
- Detailed design
- Question Engine 2 Developer docs:
- Implementation plan
- Testing
This page explains how to go about writing a new question behaviour for the new Moodle question engine.
Previous section: Overview_of_the_Moodle_question_engine
To learn to write question behaviours, you are highly encouraged to read the code of some of the existing behaviours in the Moodle code base. The code for most behaviours is shorter than these instructions!
Also note that all the question engine code has extensive PHP documenter 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.
This document assumes that you understand the data model, where a question attempt comprises a number of steps, and each step has some properties like a state and a mark, and an array of submitted data. This is explained in the overview_of_the_Moodle_question_engine.
File layout
A question behaviour plugin lives in a folder in question/behaviour. The layout inside that folder follows the typical layout for a Moodle plugin. For example, inside question/behaviour/mybehaviour/ we would have:
- behaviour.php
- This contains the definition of the qbehaviour_mybehaviour class, which should extend the question_behaviour base class.
- renderer.php
- This contains the definition of the qbehaviour_mybehaviour_renderer class, which should extend the qbehaviour_renderer base class.
- behaviourtype.php
- This contains the definition of the qbehaviour_mybehaviour_type class, which should extend the question_behaviour_type base class.
- tests/...
- Contains the unit tests and acceptance tests for this behaviour. You are strongly encouraged to write thorough unit tests to ensure the correctness of your behaviour.
- lang/en/qbehaviour_mybehaviour.php
- English language strings for this behaviour. The language file must define at least the standard 'pluginname' giving the name of the behaviour, for example $string['pluginname'] = 'My behaviour';.
Note that question behaviours are not expected to have their own database tables or capabilities. Therefore, there should not be a db sub-folder. Similarly, it would probably work to write a cron routine or fire events from your behaviour, but you probably should not do that.
Question behaviour class
One instance of this class will be created for each question_attempt that uses this behaviour.
This documentation just lists the methods that you are likely to need to override. See the PHPdocumentor comments for a complete API documentation.
Class declaration
The class name for the behaviour class must be the plugin name (e.g. mybehaviour) prefixed by qbehaviour_. You must extend the question_behaviour base class, or another behaviour class.
class qbehaviour_mybehaviour extends question_behaviour {
// ...
}
You should not need to override the constructor.
Fields
When a behaviour class is created, the fields qa and question are initialised to point to the question attempt and question that this behaviour is being used by.
From the base class code:
// From the base class.
/** @var question_attempt */
protected $qa;
/** @var question_definition */
protected $question;
public function __construct(question_attempt $qa) {
$this->qa = $qa;
$this->question = $qa->get_question();
// ...
}
Methods used during attempts at a question
required_question_definition_class()
Most behaviours can only work with a particular subclasses of question_definition. For example, they may only be able to work work with questions that are question_automatically_gradables. This method lets the behaviour document that. The type of question passed to the constructor is then checked against the class name returned by this function.
Example from qbehaviour_deferredfeedback:
public function required_question_definition_class() {
return 'question_automatically_gradable';
}
get_min_fraction()
Returns the smallest possible fraction that this behaviour, applied to this question, may give. Normally this will be the min fraction of the question type, however some behaviours (for example CBM) manipulate the question scores, and so need to return an adjusted min_fraction.
For example, from qbehaviour_deferredfeedback:
public function get_min_fraction() {
return $this->question->get_min_fraction();
}
From qbehaviour_deferredcbm:
public function get_min_fraction() {
return question_cbm::adjust_fraction(
parent::get_min_fraction(), question_cbm::HIGH);
}
get_expected_data()
Some behaviours display additional form controls. When the question form is submitted, these submitted values are read using the standard optional_param function. The question engine needs to know what parameters to look for, and with what types.
The get_expected_data function should return an array of expected parameters, with the corresponding param type. Note that the array of expected parameters may depend on the current state of the question attempt. For example, from qbehaviour_deferredcbm
public function get_expected_data() {
if (question_state::is_active($this->qa->get_state())) {
return array('certainty' => PARAM_INT);
}
return parent::get_expected_data();
}
init_first_step()
You are unlikely to need to override this method. This is called when the question attempt is actually stared. It gives the behaviour a chance to do any necessary initialisation, including passing on the request to the question type. This is, for example, how the choices in a multiple choices question are shuffled.
From the base class:
public function init_first_step(question_attempt_step $step) {
$this->question->init_first_step($step);
}
process_action()
This is the most important method. It controls what happens when someone does something with the question (other than starting it, which is handled by init_first_step.)
When the method is called, a pending attempt step is passed in. This method can either return question_attempt::DISCARD, to indicate nothing interesting happened, and the pending step should be discarded. Or, it can update that step, and return question_attempt::KEEP, to have the new step retained.
Typically, the method is implemented by examining the current state of the attempt, and the pending step, to decide what sort of action this is, and then dispatching to a more specific method.
For example, from qbehaviour_deferredfeedback
public function process_action(question_attempt_step $pendingstep) {
if ($pendingstep->has_behaviour_var('comment')) {
return $this->process_comment($pendingstep);
} else if ($pendingstep->has_behaviour_var('finish')) {
return $this->process_finish($pendingstep);
} else {
return $this->process_save($pendingstep);
}
}
Note that there are some special actions that every behaviour must be able to handle:
- behaviour_var comment => '... the comment ...'
- If this question has marks, then there will also be a behaviour_var mark and a behaviour_var maxmark. This is the action that is fired when a user manually grades a question.
- behaviour_var finish => 1
- This is the action that is generated when the user submits and finishes a whole usage. For example when they click 'Submit all and finish' in a quiz.
The base class implements the process_comment method in a way that should be suitable for most behaviours. There is also a subclass question_behaviour_with_save of question_behaviour which provides a process_save implementation that may be suitable for most behaviours. You may find it helps to extend this class instead of question_behaviour.
process_...()
So, the process_action function has dispatched to a more specific process_... function. What should that function do? Normally, it has to delegate some processing to the question type, and based on the results of that, upgrade the $pendingstep.
For example, from qbehaviour_deferredfeedback
public function process_finish(question_attempt_pending_step $pendingstep) {
if ($this->qa->get_state()->is_finished()) {
return question_attempt::DISCARD;
}
$response = $this->qa->get_last_step()->get_qt_data();
if (!$this->question->is_gradable_response($response)) {
$pendingstep->set_state(question_state::$gaveup);
} else {
list($fraction, $state) = $this->question->grade_response($response);
$pendingstep->set_fraction($fraction);
$pendingstep->set_state($state);
}
$pendingstep->set_new_response_summary($this->question->summarise_response($response));
return question_attempt::KEEP;
}
First, if the question attempt has already been finished, there is nothing to do, so question_attempt::DISCARD is returned.
If there is something to do, the response data is extracted from the $pendingstep. The question type is asked whether the data there is complete enough to be graded. If not, the state is set to 'gave up'. If there is, the question type is asked to compute the fraction, and that and the state are put into the $pendingstep. Finally, the response summary, which is displayed in places like the quiz reports is updated and question_attempt::KEEP is returned.
adjust_display_options()
This method is called before a question is rendered, so that the behaviour can change the display options depending on the current state of the question. So, for example, after the question is finished, it should be displayed in read-only mode. Before the student had submitted an answer, no feedback should be displayed.
For example, the base class does
public function adjust_display_options(question_display_options $options) {
if ($this->qa->get_state()->is_finished())) {
$options->readonly = true;
$options->numpartscorrect = $options->numpartscorrect &&
$this->qa->get_state()->is_partially_correct() &&
!empty($this->question->shownumcorrect);
} else {
$options->hide_all_feedback();
}
}
get_state_string()
In the info box that starts each question, the is a few words summary of the current state of the question. That summary is generated by this method. It is not often necessary to override the base class implementation:
public function get_state_string($showcorrectness) {
return $this->qa->get_state()->default_string($showcorrectness);
}
An example where it is overridden is qbehaviour_interactive, if you want to look.
get_our_resume_data()
This method is required to make the quiz feature Each attempt builds on last work. It needs to return any behaviour variable from the current attempt that would be needed to start a new attempt in the same state.
For example, from qbehaviour_deferredcbm:
protected function get_our_resume_data() {
$lastcertainty = $this->qa->get_last_behaviour_var('certainty');
if ($lastcertainty) {
return array('-certainty' => $lastcertainty);
} else {
return array();
}
}
protected function get_our_resume_data() {
return array();
}
Methods used for reporting
As well as just processing a single attempt as it happens, we need to be able to run reports of what happened across lots of attempts, for example to determine whether questions are performing well or badly.
Some of these methods in this section are called during the attempt, and the result stored in the database at that time, rather than when the report is run. That is just a performance consideration. Logically, these methods are all for reporting, so I list them here.
get_correct_response()
In fact, this first method is not even used for the reports. It is used in the question preview pop-up window to make the 'Fill in correct response' button work. It returns the behaviour data that needs to be submitted as part of a correct response. Obviously, this is then combined with data from the question type.
For example, from qbehaviour_deferredcbm
public function get_correct_response() {
if ($this->qa->get_state()->is_active()) {
return array('certainty' => question_cbm::HIGH);
}
return array();
}
get_question_summary()
This method returns a plain-text summary of the question that was asked. This is used, for example, by the quiz Responses report. Normally the base-class implementation that just delegates to the question type is all you need.
public function get_question_summary() {
return $this->question->get_question_summary();
}
get_right_answer_summary()
This should return a plain-text summary of what the right answer to the question is. For example, in the base class:
public function get_right_answer_summary() {
return $this->question->get_right_answer_summary();
}
and in qbehaviour_deferredcbm:
public function get_right_answer_summary() {
$summary = parent::get_right_answer_summary();
return $summary . ' [' . question_cbm::get_string(question_cbm::HIGH) . ']';
}
(Hmm. I probably should not be concatenating language strings like that.)
classify_response()
This is used by the response analysis in the quiz statistics report: At the moment, no behaviours do more than delegate to the question type:
public function classify_response() {
return $this->question->classify_response($this->qa->get_last_qt_data());
}
summarise_action()
This is used by the response history table that is shown underneath questions on the quiz review page, in the Action column. It should return a plain text representation of the action the student took in this step. For example, from qbehaviour_deferredfeedback:
public function summarise_action(question_attempt_step $step) {
if ($step->has_behaviour_var('comment')) {
return $this->summarise_manual_comment($step);
} else if ($step->has_behaviour_var('finish')) {
return $this->summarise_finish($step);
} else {
return $this->summarise_save($step);
}
}
from question_behaviour_with_save
public function summarise_save(question_attempt_step $step) {
$data = $step->get_submitted_data();
if (empty($data)) {
return $this->summarise_start($step);
}
return get_string('saved', 'question',
$this->question->summarise_response($step->get_qt_data()));
}
and from the base class:
public function summarise_start($step) {
return get_string('started', 'question');
}
public function summarise_finish($step) {
return get_string('attemptfinished', 'question');
}
Renderer class
Renderers are all about generating HTML output. The overall output of questions is controlled by the core_question_renderer::question(...) method, which in turn calls other methods of the core question renderer, the behaviour renderer and the question type renderer, to generate the various bits of output. See this overview of the parts of a question.
Note that most of the methods take a question_display_options object that specifies what should and should not be visible. For example, should feedback, or marks information, be displayed. It is important that your renderer respects these options.
Once again, in what follows, I only list the methods your are most likely to want to override.
controls
This is a block of output in the question formulation area. It goes after all the question type output in this area.
Two examples. This method is used by the interactive behaviour to show the Submit button (if the question is in an appropriate state). It is used by the CBM behaviour to show the certainty choices. Here is the interactive behaviour code:
public function controls(question_attempt $qa, question_display_options $options) {
return $this->submit_button($qa, $options);
}
submit_button is a helper method provided by the base class.
feedback
This is a block of output in the feedback area of the question. It is used, for example, in interactive behaviour, to show the Try again button, and in the CBM behaviour to output some text that explains how the score was adjusted. Here is the interactive behaviour code:
public function feedback(question_attempt $qa, question_display_options $options) {
if (!$qa->get_state()->is_active() || !$options->readonly) {
return '';
}
// Set up $attributes array ...
$output = html_writer::empty_tag('input', $attributes);
if (empty($attributes['disabled'])) {
$output .= print_js_call('question_init_submit_button',
array($attributes['id'], $qa->get_slot()), true);
}
return $output;
Note that we set up a call to the JavaScript function question_init_submit_button, passing the button id, and the question slot number. You should do that for any button you output that will cause the question to be submitted.
Question behaviour type class
The behaviour type class provides information about the behaviour in general. In contrast the behaviour class handles the behaviour applied to one particular question_attempt.
Note that, before Moodle 2.6, there was not a separate behaviour type class. These methods either did not exist, or were static methods on the behaviour class.
An activity using questions needs to provide various options to the user that relate to behaviours. For example, on the quiz settings form, we need to display a menu How questions behave where the teacher can choose which behaviour they want. The quiz does this by calling question_engine::get_behaviour_options(). Similarly, when selecting the display options Users may review, During the attempt, not all options are relevant to all behaviours, The quiz can get the appropriate dependencies by calling question_engine::get_behaviour_unused_display_options(). Naturally, the question engine needs to consult the behaviours to implement these methods This is done using the following two things.
is_archetypal()
Some behaviours should be offered as options in the user interface. For example CBM, interactive, and so on. Others are for internal use. For example qbehaviour_informationitem, qbehaviour_interactiveadaptedformyqtype. This is indicated by the return value of the is_archetypal method. True means that this behaviour will be offered as an option in the UI. This defaults to false in the base class, so you need to override the method if you want your behaviour to be an option that users select.
public function is_archetypal() {
return true;
}
get_unused_display_options()
This method should return a list of question_display_options field that are irrelevant before question_usage_by_activity::finish_all_questions() is called.
For example, from qbehaviour_deferredfeedback_type:
public static function get_unused_display_options() {
return array('correctness', 'marks', 'specificfeedback',
'generalfeedback', 'rightanswer');
}
adjust_random_guess_score()
Questions are able to report an estimate of the mark a student would get by guessing. Naturally, question behaviours that modify the marks need to be able to manipulate that. For example in qbehaviour_deferredcbm_type:
public static function adjust_random_guess_score($fraction) {
return question_cbm::adjust_fraction($fraction, question_cbm::default_certainty());
}
summarise_usage()
This method is an opportunity for the behaviour to provide some overall summary information about all the question_attempts in a question_usage_by_activity. An example of where this is done is in qbehaviour_deferredcbm_type. To see one place (currently the only place) where that is used, have a look at the quiz review page for a quiz attempt using one of the CBM behaviours.
Unit tests
For a general introduction, see the Moodle unit testing documentation.
Most of the behaviours are currently tested by working one or more test questions through a sequence of example inputs and testing the results. To do this they sub-class qbehaviour_walkthrough_test_base which provides a lot of helper methods to facilitate writing tests like that.
The best way to start is probably to look at the tests for some of the standard behaviours. For example qbehaviour_deferredfeedback_walkthrough_test::test_deferredfeedback_feedback_multichoice_single
public function test_deferredfeedback_feedback_multichoice_single() {
// Create a true-false question with correct answer true.
$mc = test_question_maker::make_a_multichoice_single_question();
$rightindex = $this->get_mc_right_answer_index($mc);
// Start a deferred feedback attempt and add the question to it.
$this->start_attempt_at_question($mc, 'deferredfeedback', 3);
// Verify.
$this->check_current_state(question_state::$todo);
$this->check_current_mark(null);
$this->check_current_output(
$this->get_contains_question_text_expectation($mc),
$this->get_contains_mc_radio_expectation(0, true, false),
$this->get_contains_mc_radio_expectation(1, true, false),
$this->get_contains_mc_radio_expectation(2, true, false),
$this->get_does_not_contain_feedback_expectation());
// Process the data extracted for this question.
$this->process_submission(array('answer' => $rightindex));
// Verify.
$this->check_current_state(question_state::$complete);
$this->check_current_mark(null);
$this->check_current_output(
$this->get_contains_mc_radio_expectation($rightindex, true, true),
$this->get_contains_mc_radio_expectation(($rightindex + 1) % 3, true, false),
$this->get_contains_mc_radio_expectation(($rightindex + 1) % 3, true, false),
$this->get_does_not_contain_correctness_expectation(),
$this->get_does_not_contain_feedback_expectation());
// Finish the attempt.
$this->quba->finish_all_questions();
// Verify.
$this->check_current_state(question_state::$gradedright);
$this->check_current_mark(3);
$this->check_current_output(
$this->get_contains_mc_radio_expectation($rightindex, false, true),
$this->get_contains_correct_expectation());
// Now change the correct answer to the question, and regrade.
$mc->answers[13]->fraction = -0.33333333;
$mc->answers[14]->fraction = 1;
$this->quba->regrade_all_questions();
// Verify.
$this->check_current_state(question_state::$gradedwrong);
$this->check_current_mark(-1);
$this->check_current_output(
$this->get_contains_incorrect_expectation());
}
As you can see, the helper methods hide a lot of the details, like starting an attempt, but if you browse the code, you will be able to see exactly what those (quite simple) helper functions do. I was going to summarise here what the test does, but I think the comments in the code already do that well enough.
See also
In the next section, Developing a Question Type I describe what a developer will need to do to create a Question Type plugin for the new system.
- The PHP documenter comments that explain the purposes of every method in the question engine code.
- Back to Question Engine 2