Note:

If you want to create a new page for developers, you should create it on the Moodle Developer Resource site.

Import/export for questiontype plugins

From MoodleDocs

This is a proposal currently in development

Motivation

While it is easy enough to add import and export capability for core questiontypes there is no clear way to allow contributed questiontype authors to enhance their plugin with import and export code. This is an outline proposal for including hooks for that functionality. It is primarily aimed at the Moodle XML format (the most comprehensive import/export format) but with a definite goal not to exclude any other format that may wish to use it.

New Methods in Questiontype Class

The main mechanism for this process will be new methods for import and export to be added to the questiontype class. There would be a discrete method for each supported format type. Note that these will not (and should not) be implemented for core question types.

Import

Implement a method for each import format that you wish to handle in the questiontype class. The naming convention is import_to_format (for example import_to_gift, import_to_xml). The method is responsible for checking the supplied data and returning a question object suitable for the save_question_options() method in the appropriate questiontype class. The method must check in particular that the data is correct for the questiontype and return a false if it is not. This is because import methods will be polled to find one to handle the data, as the correct question type cannot always be established in the general case.

The template for one of these methods would be (using import_to_xml as an example) as follows:

   /**
    * Provide import functionality for xml format
    * @param data mixed the segment of data containing the question
    * @param question object question object processed (so far) by standard import code
    * @param format object the format object so that helper methods can be used (in particular error() )
    * @param extra mixed any additional format specific data that may be passed by the format (see format code for info)
    * @return object question object suitable for save_options() call or false if cannot handle
    */
   function import_to_xml( $data, $question, $format, $extra=null ) {
       if ($data->.......
           ....
           return $question;
       }
       else {
           return false;
       }
   } 

Notes:

  • The $question parameter will contain whatever the core routines have managed to work out so far
  • The $format object is the calling object in case there are any useful methods. In particular this method should call $format->error() to report errors properly

Export

For each export format that your question plugin supports write a method called export_to_format (for example, export_to_gift, export_to_xml). The method simply takes the question object (use print_r() to see what it looks like) and returns a string containing the lines to append to the exported file. Returning false will be regarded as an error condition.

The template for one of these methods (using xml format as an example) could be:

   /**
    * Provide export functionality for xml format
    * @param question object the question object
    * @param format object the format object so that helper methods can be used 
    * @param extra mixed any additional format specific data that may be passed by the format (see format code for info)
    * @return string the data to append to the output buffer or false if error
    */
   function export_to_xml( $question, $format, $extra=null ) {
       ....
       return $xmlquestion;
   }

New methods is qformat_default class (format.php)

Two new methods (one for import and one for export) will be provided for the format plugin to call to handle situations that they cannot handle themselves. These would be called from the format classes in an appropriate place (ie, when they "decide" that they cannot handle the question internally)

Import

As import cannot, in the general sense, matching unhandled question types to the classes (as names may well differ) the installed questiontypes will be polled to find the (first) one that will handle the data. The import/export format should simply call $this->import_plugin() when it is unable to handle the question data itself. It will either return the $question object or false if it fails. The format class retains the responsibility of reporting unhandled question types. This functionality is optional for import/output formats.

  /**
    * Import for questiontype plugins
    * @param data mixed the segment of data containing the question
    * @param question object question object processed (so far) by standard import code
    * @param format object the format object so that helper methods can be used (in particular error() )
    * @param extra mixed any additional format specific data that may be passed by the format (see format code for info)
    * @return object question object suitable for save_options() call or false if cannot handle
    */
   function import_plugin( $data, $question, $format, $extra=null ) {
       global $QTYPES;
       $formatname = substr(get_class( $this ), length('qformat_'));
       $methodname = "import_to_$formatname";
       foreach ($QTYPES as $qtype) {
           if (method_exists( $qtype, $methodname )) {
               if ($question = $qtype->$methodname( $data, $question, $format, $extra )) {
                   return $question;
               }
           }
       }
       return false;
   }

Export

The export routine simpler, as the questiontype name will always be known. If the export routine encounters a question type that it cannot handle it should call $this->export_plugin(). If the question is handled the function will return a string containing the exported data. If it cannot be handled the function returns false.

/**

    * Provide export functionality for xml format
    * @param name questiontype name
    * @param question object the question object
    * @param format object the format object so that helper methods can be used 
    * @param extra mixed any additional format specific data that may be passed by the format (see format code for info)
    * @return string the data to append to the output buffer or false if error
    */
   function export_plugin( $name, $question, $format, $extra=null ) {
       global $QTYPES;
       $formatname = substr(get_class( $this ), length('qformat_'));
       $methodname = "export_to_$formatname";
       if (array_key_exists( $name, $QTYPES )) {
           $qtype = $QTYPES[ $name ];
           if (method_exists( $qtype, $methodname )) {
               if ( $data = $qtype->$methodname( $question, $format, $extra )) {
                   return $data;
               }
           }
       }
       return false;
   }

Question name resolution

One issue is that the name of the (type of a) question may differ, indeed is likely to differ from the name of the Moodle questiontype plugin. This makes it problematic knowing which plugin to call to potentially handle the question. My solution is to "poll" the questiontypes one-by-one to see if any will handle the question. The first that returns a response other than false will be accepted.

Error handling

The recommended way for import/export formats to report a syntax error is to call the error() method within the format class. The format object is passed to the questiontype method so that $format->error can be called.

Alternate proposal from Tim

I don't think that this proposal is a natural way to do things. If I was writing a 3rd-party question type, then I would expect to have to write some functions of the form

function export_to_gift( $formatversion, $question, $format ) { ... } 
function export_to_moodlexml( $formatversion, $question, $format ) { ... } 
//etc.

Also, I have often felt that most of the existing export format code (some of it very old) is not structured as well as possible. I feel that in, say qformat_gift, there should just be a bunch of functions

function export_shortanswer( $formatversion, $question, $format ) { ... } 
function export_numerical( $formatversion, $question, $format ) { ... } 
function export_multiplechoice( $formatversion, $question, $format ) { ... } 
//etc.

which probably all do some qtype-specific work, and then call a helper method to do the bit that is common to all question types. The parameters and return types for these methods would be as Howard proposes above.

So, what I think this suggests is that for each question type, and each format, there is some function somewhere that does the import or export (or there isn't because that type is not supported for that format).

So each format class (which is only loaded when it is used) should, when it is created, build up two arrays, export_functions, and import_functions. The keys in these arrays would be question type names, and the values would be callbacks - that is, basically the name of a function or method.

Howard correctly points out the issues with question type names on import. For the import_functions array, the keys would be the question type names as used in the import file. This assumes that for all formats we might want to support, it is possible for the main question type class to inspect the data for a question, and get some sort of string that can be considered a 'question type'. I'm not sure if this is true. (QTI is the format I am thinking of as a potential problem, but I know of no definite plans to support that in full generality, and it would not be impossible to do even here.)

To set up these arrays, there would be a function in the qformat base class, that would fill in the arrays for the types it supports, and then polls each question type for more information. Something like:

// In qformat_default
    function name() {
        error('This function must be overriden to return the export format name.');
    }
    var $export_methods = array();
    var $import_methods = array();
    function init() {
        global $QTYPES;
        $methods = get_class_methods($this);
        foreach ($methods as $method) {
            if (strpos($method, 'import_') === 0) {
                $this->import_methods[substr($method, 7)] = array($this, $method);
            }
            if (strpos($method, 'export_') === 0) {
                $this->export_methods[substr($method, 7)] = array($this, $method);
            }
        }
        foreach ($QTYPES as $qtype) {
            $import_methods = $qtype->get_import_methods($this->name());
            foreach ($import_methods as $typename => $method) {
                if (!isset($this->import_methods[$typename])) {
                    $this->import_methods[$typename] = $method;
                }
            }
            if (!isset($this->export_methods[$typename])) {
                if ($import_method = $qtype->get_export_method($this->name())) {
                    $this->export_methods[$qtype->name()] = $import_method;
                }
            }
        }
    }

// In default_questiontype
    function get_export_method($formatname) {
        $methodname = 'export_' . $formatname;
        if (method_exists($this, $methodname)) {
            return array($this, $methodname);
        }
        return false;
    }
    function get_import_methods($formatname) {
        $methodname = 'import_' . $formatname;
        if (method_exists($this, $methodname)) {
            return array(
                $this->name() => array($this, $methodname)
            );
        }
        return array();
    }

Of course, any of these methods could be overridden by a subclass. In the short term, we might also need to allow some magic value (e.g. '-') in these arrays, instead of a callback, meaning that the particular format class handles this type using the old, differently-structured code, rather than one of the new methods.

Then, the main import and export methods in qformat_default would just need to be (I'm oversimplifying here):

    function exportprocess() {
        $questions = get_questions_category( $this->category, true );
        $expout = ''; 
        foreach ($questions as $question) {
            if (isset($this->export_functions[$question->type])) {
                $expout .= call_user_func($this->export_functions[$question->type], $version, $question, $this);
            } else {
                // Display some message about unsuported type.
            }
        }
        // Rest of function as before.
    }

    function importprocess() {
        $this->openfile();
        $questions = array();
        while ($data = $this->load_data_for_next_question()) {
            $type = $this->deduce_qtype($data);
            if (isset($this->import_functions[$type])) {
                $question = $this->parse_common_parts($data);
                call_user_func($this->import_functions[$type], $version, $type, $data, $question, $this);
                $questions[] = $question;
            } else {
                // Display some message about unsuported type.
            }
        }
        // Rest of function as before.
    }

Once all this is in place. All question type authors have to do to support a particular format is write the methods like 'import_gift' and 'export_gift'. That is consistent with my goal of making it as easy as possible to write new question types.

But to carry this proposal through is a lot of work refactoring the existing import and export classes. Which may be more than Howard bargained for.


(I've just noticed, all the import and export works by doing everything in memory. That is not very scalable, even thought it leads to better error handling. We can save that problem for another day.)

See Also