From d4d64ee3f1c68193298fb69ff095172e72edc9da Mon Sep 17 00:00:00 2001 From: Tim Hunt Date: Mon, 24 Jan 2011 14:54:21 +0000 Subject: [PATCH] MDL-20636 Bug 11406 Opaque gives radio groups name _rg by default, which QE cannot handle. As a work around, we specifically look for this variable, and if it is present, include it in the sumbitted data. --- question/behaviour/opaque/behaviour.php | 5 +- question/type/opaque/locallib.php | 1610 ++++++++++++----------- 2 files changed, 815 insertions(+), 800 deletions(-) diff --git a/question/behaviour/opaque/behaviour.php b/question/behaviour/opaque/behaviour.php index 64dcaa4fb7d..bcda5547f4c 100644 --- a/question/behaviour/opaque/behaviour.php +++ b/question/behaviour/opaque/behaviour.php @@ -105,12 +105,9 @@ class qbehaviour_opaque extends question_behaviour { } else if ($step->has_behaviour_var('comment')) { return $this->summarise_manual_comment($step); } else { - $data = $step->get_qt_data(); + $data = qtype_opaque_get_submitted_data($step); $formatteddata = array(); foreach ($data as $name => $value) { - if (substr($name, 0, 1) == '_') { - continue; - } $formatteddata[] = $name . ' => ' . s($value); } if ($formatteddata) { diff --git a/question/type/opaque/locallib.php b/question/type/opaque/locallib.php index 16b71c60f3c..f8865baa9dc 100644 --- a/question/type/opaque/locallib.php +++ b/question/type/opaque/locallib.php @@ -1,796 +1,814 @@ -. - - -/** - * Library routines used by the Opaque question type. - * - * @package qtype - * @subpackage opaque - * @copyright 2006 The Open University - * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later - */ - - -require_once($CFG->libdir . '/soaplib.php'); -require_once($CFG->libdir . '/xmlize.php'); - - -/** User passed on question. Should match the definition in Om.question.Results. */ -define('OPAQUE_ATTEMPTS_PASS', 0); -/** User got question wrong after all attempts. Should match the definition in om.question.Results. */ -define('OPAQUE_ATTEMPTS_WRONG', -1); -/** User got question partially correct after all attempts. Should match the definition in om.question.Results. */ -define('OPAQUE_ATTEMPTS_PARTIALLYCORRECT', -2); -/** If developer hasn't set the value. Should match the definition in om.question.Results. */ -define('OPAQUE_ATTEMPTS_UNSET', -99); -/** Prefix used for CSS files. */ -define('OPAQUE_CSS_FILENAME_PREFIX', '__styles_'); - -/** - * @return an array id -> enginename, that can be used to build a dropdown - * menu of installed question types. - */ -function qtype_opaque_installed_engine_choices() { - global $DB; - return $DB->get_records_menu('question_opaque_engines', array(), 'name ASC', 'id, name'); -} - -function qtype_opaque_format_soap_fault($fault) { - foreach (array('faultcode', 'faultactor', 'faultstring', 'faultdetail') as $field) { - if (empty($fault->$field)) { - $fault->$field = ''; - } - } - return get_string('soapfault', 'qtype_opaque', $fault); -} - - -/** - * Manages loading and saving question engine definitions to and from the database. - * - * @copyright 2010 The Open University - * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later - */ -class qtype_opaque_engine_manager { - /** - * Load the definition of an engine from the database. - * @param integer $engineid the id of the engine to load. - * @return mixed On success, and object with fields id, name, questionengines and questionbanks. - * The last two fields are arrays of URLs. On an error, returns a string to look up in the - * qtype_opaque language file as an error message. - */ - public function load_engine_def($engineid) { - global $DB; - $engine = $DB->get_record('question_opaque_engines', array('id' => $engineid), '*', MUST_EXIST); - - $engine->questionengines = array(); - $engine->questionbanks = array(); - $servers = $DB->get_records('question_opaque_servers', array('engineid' => $engineid), 'id ASC'); - if (!$servers) { - throw new moodle_exception('couldnotloadengineservers', 'qtype_opaque', '', $engineid); - } - foreach ($servers as $server) { - if ($server->type == 'qe') { - $engine->questionengines[] = $server->url; - } else if ($server->type == 'qb') { - $engine->questionbanks[] = $server->url; - } else { - throw new moodle_exception('unrecognisedservertype', 'qtype_opaque', '', $engineid); - } - } - return $engine; - } - - /** - * Save or update an engine definition in the database, and returm the engine id. The definition - * will be created if $engine->id is not set, and updated if it is. - * - * @param object $engine the definition to save. - * @return integer the id of the saved definition. - */ - public function save_engine_def($engine) { - global $DB; - $transaction = $DB->start_delegated_transaction(); - - if (!empty($engine->id)) { - $DB->update_record('question_opaque_engines', $engine); - } else { - $engine->id = $DB->insert_record('question_opaque_engines', $engine); - } - $DB->delete_records('question_opaque_servers', array('engineid' => $engine->id)); - $this->store_opaque_servers($engine->questionengines, 'qe', $engine->id); - $this->store_opaque_servers($engine->questionbanks, 'qb', $engine->id); - - $transaction->allow_commit(); - return $engine->id; - } - - /** - * Save a list of servers of a given type in the question_opaque_servers table. - * - * @param array $urls an array of URLs. - * @param string $type 'qe' or 'qb'. - * @param int $engineid - */ - protected function store_opaque_servers($urls, $type, $engineid) { - global $DB; - foreach ($urls as $url) { - $server = new stdClass(); - $server->engineid = $engineid; - $server->type = $type; - $server->url = $url; - $DB->insert_record('question_opaque_servers', $server, false); - } - } - - /** - * Delete the definition of an engine from the database. - * @param integer $engineid the id of the engine to delete. - * @return boolean whether the delete succeeded. - */ - public function delete_engine_def($engineid) { - global $DB; - $transaction = $DB->start_delegated_transaction(); - $DB->delete_records('question_opaque_servers', array('engineid' => $engineid)); - $DB->delete_records('question_opaque_engines', array('id' => $engineid)); - $transaction->allow_commit(); - } - - protected function get_possibly_matching_engines($engine) { - global $DB; - - // First we try to get a reasonably accurate guess with SQL - we load - // the id of all engines with the same passkey and which use the first - // questionengine and questionbank (if any). - $tables = array('FROM {question_opaque_engines} e'); - $conditions = array('e.passkey = :passkey'); - $params = array('passkey' => $engine->passkey); - if (!empty($engine->questionengines)) { - $qeurl = reset($engine->questionengines); - $tables[] = "JOIN {question_opaque_servers} qe ON - qe.engineid = e.id AND qe.type = 'qe'"; - $conditions[] = 'qe.url = :qeurl'; - $params['qeurl'] = $qeurl; - } - if (!empty($engine->questionbanks)) { - $qburl = reset($engine->questionbanks); - $tables[] = "JOIN {question_opaque_servers} qb ON - qb.engineid = e.id AND qb.type = 'qb'"; - $conditions[] = 'qb.url = :qburl'; - $params['qburl'] = $qburl; - } - return $DB->get_records_sql_menu(' - SELECT e.id,1 ' . implode(' ', $tables) . ' WHERE ' . - implode(' AND ', $conditions), $params); - } - - /** - * If an engine definition like this one (same passkey and server lists) already exists - * in the database, then return its id, otherwise save this one to the database and - * return the new engine id. - * - * @param object $engine the engine to ensure is in the databse. - * @return integer its id. - */ - public function find_or_create_engineid($engine) { - $possibleengineids = $this->get_possibly_matching_engines($engine); - - // Then we loop through the possibilities loading the full definition and comparing it. - if ($possibleengineids) { - foreach ($possibleengineids as $engineid => $ignored) { - $testengine = $this->load_engine_def($engineid); - $testengine->passkey = addslashes($testengine->passkey); - if ($this->is_same_engine($engine, $testengine)) { - return $engineid; - } - } - } - - return $this->save_engine_def($engine); - } - - /** - * Are these two engine definitions essentially the same (same passkey and server lists)? - * - * @param object $engine1 one engine definition. - * @param object $engine2 another engine definition. - * @return boolean whether they are the same. - */ - public function is_same_engine($engine1, $engine2) { - // Same passkey. - $ans = $engine1->passkey == $engine2->passkey && - // Same question engines. - !array_diff($engine1->questionengines, $engine2->questionengines) && - !array_diff($engine2->questionengines, $engine1->questionengines) && - // Same question banks. - !array_diff($engine1->questionbanks, $engine2->questionbanks) && - !array_diff($engine2->questionbanks, $engine1->questionbanks); - return $ans; - } -} - -/** - * Load the definition of an engine from the database. - * @param integer $engineid the id of the engine to load. - * @return mixed On success, and object with fields id, name, questionengines and questionbanks. - * The last two fields are arrays of URLs. On an error, returns a string to look up in the - * qtype_opaque language file as an error message. - */ -function qtype_opaque_load_engine_def($engineid) { - $manager = new qtype_opaque_engine_manager(); - return $manager->load_engine_def($engineid); -} - -/** - * Save or update an engine definition in the database, and returm the engine id. The definition - * will be created if $engine->id is not set, and updated if it is. - * - * @param object $engine the definition to save. - * @return integer the id of the saved definition. - */ -function qtype_opaque_save_engine_def($engine) { - $manager = new qtype_opaque_engine_manager(); - return $manager->save_engine_def($engine); -} - -/** - * Delete the definition of an engine from the database. - * @param integer $engineid the id of the engine to delete. - * @return boolean whether the delete succeeded. - */ -function qtype_opaque_delete_engine_def($engineid) { - $manager = new qtype_opaque_engine_manager(); - return $manager->delete_engine_def($engineid); -} - -/** - * If an engine definition like this one (same passkey and server lists) already exists - * in the database, then return its id, otherwise save this one to the database and - * return the new engine id. - * - * @param object $engine the engine to ensure is in the databse. - * @return integer its id. - */ -function qtype_opaque_find_or_create_engineid($engine) { - $manager = new qtype_opaque_engine_manager(); - return $manager->find_or_create_engineid($engine); -} - -/** - * @param mixed $engine either an $engine object, or the URL of a particular question engine server. - * @return a soap connection, either to the specific URL give, or to to one of the question engine servers - * of this $engine object picked at random. returns a string to look up in the qtype_opaque - * language file as an error message if a problem arises. - */ -function qtype_opaque_connect($engine) { - if (is_string($engine)) { - $url = $engine; - } else if (!empty($engine->urlused)) { - $url = $engine->urlused; - } else { - $url = $engine->questionengines[array_rand($engine->questionengines)]; - } - $connection = new SoapClient($url . '?wsdl', array('soap_version'=>SOAP_1_1, 'exceptions'=>true)); - if (!is_string($engine)) { - $engine->urlused = $url; - } - return $connection; -} - -/** - * @param mixed $engine either an $engine object, or the URL of a particular question engine server. - * @return some XML, as parsed by xmlize, on success, or a string to look up in the qtype_opaque - * language file as an error message. - */ -function qtype_opaque_get_engine_info($engine) { - $connection = qtype_opaque_connect($engine); - $getengineinforesult = $connection->getEngineInfo(); - return xmlize($getengineinforesult); -} - -/** - * @param mixed $engine either an $engine object, or the URL of a particular question engine server. - * @return The question metadata, as an xmlised array, so, for example, - * $metadata[questionmetadata][@][#][scoring][0][#][marks][0][#] is the maximum possible score for - * this question. - */ -function qtype_opaque_get_question_metadata($engine, $remoteid, $remoteversion) { - $connection = qtype_opaque_connect($engine); - $questionbaseurl = $engine->questionbanks[array_rand($engine->questionbanks)]; - $getmetadataresult = $connection->getQuestionMetadata($remoteid, $remoteversion, $questionbaseurl); - return xmlize($getmetadataresult); -} - -/** - * @param object $engine the engine to connect to. - * @param string $remoteid - * @param string $remoteversion - * @param int $randomseed - * @return mixed the result of the soap call on success, or a string error message on failure. - */ -function qtype_opaque_start_question_session($engine, $remoteid, $remoteversion, $data, $cached_resources) { - $connection = qtype_opaque_connect($engine); - - $questionbaseurl = ''; - if (!empty($engine->questionbanks)) { - $questionbaseurl = $engine->questionbanks[array_rand($engine->questionbanks)]; - } - - $initialparams = array( - 'randomseed' => $data['-_randomseed'], - 'userid' => $data['-_userid'], - 'language' => $data['-_language'], - 'passKey' => qtype_opaque_generate_passkey($engine->passkey, $data['-_userid']), - 'preferredbehaviour' => $data['-_preferredbehaviour'], - ); - - return $connection->start($remoteid, $remoteversion, $questionbaseurl, - array_keys($initialparams), array_values($initialparams), $cached_resources); -} - -function qtype_opaque_process($engine, $questionsessionid, $response) { - $connection = qtype_opaque_connect($engine); - return $connection->process($questionsessionid, array_keys($response), - array_values($response)); -} - -/** - * @param string $questionsessionid the question session to stop. - * @return true on success, or a string error message on failure. - */ -function qtype_opaque_stop_question_session($engine, $questionsessionid) { - $connection = qtype_opaque_connect($engine); - $connection->stop($questionsessionid); - return true; -} - -/** - * Get a step from $qa, as if $pendingstep had already been added at the end - * of the list, if it is not null. - * @param integer $seq - * @param question_attempt $qa - * @param question_attempt_step|null $pendingstep - * @return question_attempt_step - */ -function qtype_opaque_get_step($seq, question_attempt $qa, $pendingstep) { - if ($seq < $qa->get_num_steps()) { - return $qa->get_step($seq); - } - if ($seq == $qa->get_num_steps() && !is_null($pendingstep)) { - return $pendingstep; - } - throw new Exception('Sequence number ' . $seq . ' out of range.'); -} - -/** - * Update the $SESSION->cached_opaque_state to show the current status of $question for state - * $state. - * @param object $question the question - * @param object $state - * @return mixed $SESSION->cached_opaque_state on success, a string error message on failure. - */ -function qtype_opaque_update_state(question_attempt $qa, question_attempt_step $pendingstep = null) { - global $SESSION; - - $question = $qa->get_question(); - $targetseq = $qa->get_num_steps() - 1; - if (!is_null($pendingstep)) { - $targetseq += 1; - } - - if (empty($SESSION->cached_opaque_state) || - empty($SESSION->cached_opaque_state->qaid) || - empty($SESSION->cached_opaque_state->sequencenumber)) { - $cachestatus = 'empty'; - } else if ($SESSION->cached_opaque_state->qaid != $qa->get_database_id() || - $SESSION->cached_opaque_state->sequencenumber > $targetseq) { - if (!empty($SESSION->cached_opaque_state->questionsessionid)) { - $error = qtype_opaque_stop_question_session($SESSION->cached_opaque_state->engine, - $SESSION->cached_opaque_state->questionsessionid); - if (is_string($error)) { - unset($SESSION->cached_opaque_state); - return $error; - } - } - unset($SESSION->cached_opaque_state); - $cachestatus = 'empty'; - } else if ($SESSION->cached_opaque_state->sequencenumber < $targetseq) { - $cachestatus = 'catchup'; - } else { - $cachestatus = 'good'; - } - - $resourcecache = new qtype_opaque_resource_cache($question->engineid, - $question->remoteid, $question->remoteversion); - - if ($cachestatus == 'empty') { - $SESSION->cached_opaque_state = new stdClass; - $opaquestate = $SESSION->cached_opaque_state; - $opaquestate->qaid = $qa->get_database_id(); - $opaquestate->remoteid = $question->remoteid; - $opaquestate->remoteversion = $question->remoteversion; - $opaquestate->engineid = $question->engineid; - $opaquestate->nameprefix = $qa->get_field_prefix(); - $opaquestate->questionended = false; - $opaquestate->sequencenumber = -1; - $opaquestate->resultssequencenumber = -1; - - $engine = qtype_opaque_load_engine_def($question->engineid); - if (is_string($engine)) { - unset($SESSION->cached_opaque_state); - return $engine; - } - $opaquestate->engine = $engine; - - $step = qtype_opaque_get_step(0, $qa, $pendingstep); - $startreturn = qtype_opaque_start_question_session($engine, $question->remoteid, - $question->remoteversion, $step->get_all_data(), $resourcecache->list_cached_resources()); - if (is_string($startreturn)) { - unset($SESSION->cached_opaque_state); - return $startreturn; - } - - qtype_opaque_extract_stuff_from_response($opaquestate, $startreturn, $resourcecache); - $opaquestate->sequencenumber++; - $cachestatus = 'catchup'; - } else { - $opaquestate = $SESSION->cached_opaque_state; - } - - if ($cachestatus == 'catchup') { - if ($opaquestate->sequencenumber >= $targetseq) { - $error = qtype_opaque_stop_question_session($opaquestate->engine, - $opaquestate->questionsessionid); - } - while ($opaquestate->sequencenumber < $targetseq) { - $step = qtype_opaque_get_step($opaquestate->sequencenumber + 1, $qa, $pendingstep); - - $processreturn = qtype_opaque_process($opaquestate->engine, $opaquestate->questionsessionid, $step->get_submitted_data()); - if (is_string($processreturn)) { - unset($SESSION->cached_opaque_state); - return $processreturn; - } - - if (!empty($processreturn->results)) { - $opaquestate->resultssequencenumber = $opaquestate->sequencenumber + 1; - $opaquestate->results = $processreturn->results; - } - if ($processreturn->questionEnd) { - $opaquestate->questionended = true; - $opaquestate->sequencenumber = $targetseq; - $opaquestate->xhtml = qtype_opaque_strip_omact_buttons($opaquestate->xhtml); - unset($opaquestate->questionsessionid); - break; - } - qtype_opaque_extract_stuff_from_response($opaquestate, $processreturn, $resourcecache); - - $opaquestate->sequencenumber++; - } - $cachestatus = 'good'; - } - - return $opaquestate; -} - -/** - * File name used to store the CSS of the question, question session id is appended. - */ -function qtype_opaque_stylesheet_filename($questionsessionid) { - return OPAQUE_CSS_FILENAME_PREFIX . $questionsessionid . '.css'; -} - -/** - * Pulls out the fields common to StartResponse and ProcessResponse. - * @param object $opaquestate should be $SESSION->cached_opaque_state, or equivalent. - * @param object $response a StartResponse or ProcessResponse. - * @param object $resourcecache the resource cache for this question. - * @return true on success, or a string error message on failure. - */ -function qtype_opaque_extract_stuff_from_response($opaquestate, $response, $resourcecache) { - global $CFG; - static $replaces; - - if (empty($replaces)) { - $replaces = array( - '%%RESOURCES%%' => '', // Filled in below. - '%%IDPREFIX%%' => '', // Filled in below. - '%%%%' => '%%' - ); - - $strings = array('lTRYAGAIN', 'lGIVEUP', 'lNEXTQUESTION', 'lENTERANSWER', 'lCLEAR'); - foreach ($strings as $string) { - $replaces["%%$string%%"] = get_string($string, 'qtype_opaque'); - } - } - - // Process the XHTML, replacing the strings that need to be replaced. - $xhtml = $response->XHTML; - - $replaces['%%RESOURCES%%'] = $resourcecache->file_url(''); - $replaces['%%IDPREFIX%%'] = $opaquestate->nameprefix; - $xhtml = str_replace(array_keys($replaces), $replaces, $xhtml); - - // TODO this is a nasty hack. Flash uses & as a separator in the FlashVars string, - // so we have to replce the &s with %26s in this one place only. So for now - // do it with a regexp. Longer term, it might be better to changes the file.php urls - // so they don't contain &s. - $xhtml = preg_replace_callback( - '/name="FlashVars" value="TheSound=[^"]+"/', - create_function('$matches', 'return str_replace("&", "%26", $matches[0]);'), - $xhtml); - - // Another hack to take out the next button that most OM questions include, but which does not work in Moodle. - // Actually, we remove any non-disabled buttons, and the following script tag. - // TODO think of a better way to do this. - if ($opaquestate->resultssequencenumber >= 0) { - $xhtml = qtype_opaque_strip_omact_buttons($xhtml); - } - - $opaquestate->xhtml = $xhtml; - - // Process the CSS (only when we have a StartResponse). - if (!empty($response->CSS)) { - $opaquestate->cssfilename = qtype_opaque_stylesheet_filename($response->questionSession); - $resourcecache->cache_file($opaquestate->cssfilename, 'text/css;charset=UTF-8', $response->CSS); - } - - // Process the resources. - $resourcecache->cache_resources($response->resources); - - // Process the other bits. - $opaquestate->progressinfo = $response->progressInfo; - if (!empty($response->questionSession)) { - $opaquestate->questionsessionid = $response->questionSession; - } - - if(!empty($response->head)) { - $opaquestate->headXHTML = $response->head; - } - - return true; -} - -/** - * Strip any buttons, followed by script tags, where the button has an id containing _omact_, and is not disabled. - */ -function qtype_opaque_strip_omact_buttons($xhtml) { - return preg_replace('|])*? id="[^"]*_omact_[^"]*"(?:(?!disabled=)[^>])*?>|', '', $xhtml); -} - -/** - * @param string $secret the secret string for this question engine. - * @param int $userid the id of the user attempting this question. - * @return string the passkey that needs to be sent to the quetion engine to show that - * we are allowed to start a question session for this user. - */ -function qtype_opaque_generate_passkey($secret, $userid) { - return md5($secret . $userid); -} - -/** - * OpenMark relies on certain browser-specific class names to be present in the - * HTML outside the question, in order to apply certian browser-specific layout - * work-arounds. This function re-implements Om's browser sniffing rules. See - * https://openmark.dev.java.net/source/browse/openmark/trunk/src/util/misc/UserAgent.java?view=markup - * @return string class to add to the HTML. - */ -function qtype_opaque_browser_type() { - $useragent = $_SERVER['HTTP_USER_AGENT']; - - // Filter troublemakers - if (strpos($useragent, 'KHTML') !== false) { - return "khtml"; - } - if (strpos($useragent, 'Opera') !== false) { - return "opera"; - } - - // Check version of our two supported browsers - $matches = array(); - if (preg_match('/"^.*rv:(\d+)\\.(\d+)\D.*$"/', $useragent, $matches)) { - return 'gecko-' . $matches[1] . '-' . $matches[2]; - } - if (preg_match('/^.*MSIE (\d+)\\.(\d+)\D.*Windows.*$/', $useragent, $matches)) { - return 'winie-' . $matches[1]; // Major verison only - } - - return ''; -} - -/** - * This class caches the resources belonging a particular question. - * - * There are synchronisation issues if two students are doing the same question at the same time. - */ -class qtype_opaque_resource_cache { - protected $folder; // Path to the folder where resources for this question are cached. - protected $metadatafolder; // Path to the folder where mime types are stored. - protected $baseurl; // initial part of the URL to link to a file in the cache. - - /** - * Create a new qtype_opaque_resource_cache for a particular remote question. - * @param integer $engineid the id of the question engine. - * @param string $remoteid remote question id, as per Opaque spec. - * @param string $remoteversion remote question version, as per Opaque spec. - */ - public function __construct($engineid, $remoteid, $remoteversion) { - global $CFG; - $folderstart = $CFG->dataroot . '/opaqueresources/' . $engineid . '/' . $remoteid . '/' . $remoteversion; - $this->folder = $folderstart . '/files'; - if (!is_dir($this->folder)) { - $this->mkdir_recursive($this->folder); - } - $this->metadatafolder = $folderstart . '/meta'; - if (!is_dir($this->metadatafolder)) { - $this->mkdir_recursive($this->metadatafolder); - } - $this->baseurl = new moodle_url('/question/type/opaque/file.php', array( - 'engineid' => $engineid, 'remoteid' => $remoteid, 'remoteversion' => $remoteversion)); - } - - /** - * @param string $filename the file name. - * @return the full path of a file with the given name. - */ - public function file_path($filename) { - return $this->folder . '/' . $filename; - } - - /** - * @param string $filename the file name. - * @return the full path of a file with the given name. - */ - public function file_meta_path($filename) { - return $this->metadatafolder . '/' . $filename; - } - - /** - * @param string $filename the file name. - * @return the URL to access this file. - */ - public function file_url($filename) { - return new moodle_url($this->baseurl, array('filename' => $filename)); - } - - /** - * @param string $filename the file name. - * @return the URL to access this file. - */ - public function file_mime_type($filename) { - $metapath = $this->file_meta_path($filename); - if (file_exists($metapath)) { - return file_get_contents($metapath); - } - return mimeinfo('type', $filename); - } - - /** - * @param string $filename the name of the file to look for. - * @return true if this named file is in the cache, otherwise false. - */ - public function file_in_cache($filename) { - return file_exists($this->file_path($filename)); - } - - /** - * Serve a file from the cache. - * @param string $filename the file name. - */ - public function serve_file($filename) { - if (!$this->file_in_cache($filename)) { - header('HTTP/1.0 404 Not Found'); - header('Content-Type: text/plain;charset=UTF-8'); - echo 'File not found'; - exit; - } - $mimetype = $this->file_mime_type($filename); - - // Handle If-Modified-Since - $file = $this->file_path($filename); - $filedate = filemtime($file); - $ifmodifiedsince = isset($_SERVER['HTTP_IF_MODIFIED_SINCE']) ? $_SERVER['HTTP_IF_MODIFIED_SINCE'] : false; - if($ifmodifiedsince && strtotime($ifmodifiedsince)>=$filedate) { - header('HTTP/1.0 304 Not Modified'); - exit; - } - header('Last-Modified: '.gmdate('D, d M Y H:i:s',$filedate).' GMT'); - -// // Send expire headers -// $lifetime=4*60*60; -// header('Cache-Control: max-age='.$lifetime); -// header('Expires: '. gmdate('D, d M Y H:i:s', time() + $lifetime) .' GMT'); - - // Type - header('Content-Type: ' . $mimetype); - header('Content-Length: ' . filesize($file)); - - // Output file - session_write_close(); // unlock session during fileserving - readfile($file); - } - - /** - * Store a file in the cache. - * - * @param string $filename the name of the file to cache. - * @param string $mimetype the type of the file to cache. - * @param string $content the contents to write to the file. - */ - public function cache_file($filename, $mimetype, $content) { - file_put_contents($this->file_path($filename), $content); - file_put_contents($this->file_meta_path($filename), $mimetype); - } - - /** - * Add the resources from a particular response to the cache. - * @param array $resources as returned from start or process Opaque methods. - */ - public function cache_resources($resources) { - if(!empty($resources)) { - foreach ($resources as $resource) { - $mimetype = $resource->mimeType; - if (strpos($resource->mimeType, 'text/') === 0 && !empty($resource->encoding)) { - $mimetype .= ';charset=' . $resource->encoding; - } - $this->cache_file($resource->filename, $mimetype, $resource->content); - } - } - } - - /** - * List the resources cached for this question. - * @return array list of resource names. - */ - public function list_cached_resources() { - $filepaths = glob($this->folder . '/*'); - if (!is_array($filepaths)) { - // If an error occurrs, say that we have no files cached. - $filepaths = array(); - } - $pathlen = strlen($this->folder . '/'); - $files = array(); - foreach ($filepaths as &$filepath) { - $file = substr($filepath, $pathlen); - if (strpos($file, OPAQUE_CSS_FILENAME_PREFIX) !== 0) { - $files[] = $file; - } - } - return $files; - } - - /** - * This function exists because mkdir(folder,mode,TRUE) doesn't work on our server. - * Safe to call even if folder already exists (checks) - * @param string $folder Folder to create - * @param int $mode Mode for creation (default 0755) - * @return boolean True if folder (now) exists, false if there was a failure - */ - protected function mkdir_recursive($folder, $mode='') { - if (is_dir($folder)) { - return true; - } - if ($mode == '') { - global $CFG; - $mode = $CFG->directorypermissions; - } - if (!$this->mkdir_recursive(dirname($folder),$mode)) { - return false; - } - return mkdir($folder,$mode); - } -} +. + + +/** + * Library routines used by the Opaque question type. + * + * @package qtype + * @subpackage opaque + * @copyright 2006 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + + +require_once($CFG->libdir . '/soaplib.php'); +require_once($CFG->libdir . '/xmlize.php'); + + +/** User passed on question. Should match the definition in Om.question.Results. */ +define('OPAQUE_ATTEMPTS_PASS', 0); +/** User got question wrong after all attempts. Should match the definition in om.question.Results. */ +define('OPAQUE_ATTEMPTS_WRONG', -1); +/** User got question partially correct after all attempts. Should match the definition in om.question.Results. */ +define('OPAQUE_ATTEMPTS_PARTIALLYCORRECT', -2); +/** If developer hasn't set the value. Should match the definition in om.question.Results. */ +define('OPAQUE_ATTEMPTS_UNSET', -99); +/** Prefix used for CSS files. */ +define('OPAQUE_CSS_FILENAME_PREFIX', '__styles_'); + +/** + * @return an array id -> enginename, that can be used to build a dropdown + * menu of installed question types. + */ +function qtype_opaque_installed_engine_choices() { + global $DB; + return $DB->get_records_menu('question_opaque_engines', array(), 'name ASC', 'id, name'); +} + +function qtype_opaque_format_soap_fault($fault) { + foreach (array('faultcode', 'faultactor', 'faultstring', 'faultdetail') as $field) { + if (empty($fault->$field)) { + $fault->$field = ''; + } + } + return get_string('soapfault', 'qtype_opaque', $fault); +} + + +/** + * Manages loading and saving question engine definitions to and from the database. + * + * @copyright 2010 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class qtype_opaque_engine_manager { + /** + * Load the definition of an engine from the database. + * @param integer $engineid the id of the engine to load. + * @return mixed On success, and object with fields id, name, questionengines and questionbanks. + * The last two fields are arrays of URLs. On an error, returns a string to look up in the + * qtype_opaque language file as an error message. + */ + public function load_engine_def($engineid) { + global $DB; + $engine = $DB->get_record('question_opaque_engines', array('id' => $engineid), '*', MUST_EXIST); + + $engine->questionengines = array(); + $engine->questionbanks = array(); + $servers = $DB->get_records('question_opaque_servers', array('engineid' => $engineid), 'id ASC'); + if (!$servers) { + throw new moodle_exception('couldnotloadengineservers', 'qtype_opaque', '', $engineid); + } + foreach ($servers as $server) { + if ($server->type == 'qe') { + $engine->questionengines[] = $server->url; + } else if ($server->type == 'qb') { + $engine->questionbanks[] = $server->url; + } else { + throw new moodle_exception('unrecognisedservertype', 'qtype_opaque', '', $engineid); + } + } + return $engine; + } + + /** + * Save or update an engine definition in the database, and returm the engine id. The definition + * will be created if $engine->id is not set, and updated if it is. + * + * @param object $engine the definition to save. + * @return integer the id of the saved definition. + */ + public function save_engine_def($engine) { + global $DB; + $transaction = $DB->start_delegated_transaction(); + + if (!empty($engine->id)) { + $DB->update_record('question_opaque_engines', $engine); + } else { + $engine->id = $DB->insert_record('question_opaque_engines', $engine); + } + $DB->delete_records('question_opaque_servers', array('engineid' => $engine->id)); + $this->store_opaque_servers($engine->questionengines, 'qe', $engine->id); + $this->store_opaque_servers($engine->questionbanks, 'qb', $engine->id); + + $transaction->allow_commit(); + return $engine->id; + } + + /** + * Save a list of servers of a given type in the question_opaque_servers table. + * + * @param array $urls an array of URLs. + * @param string $type 'qe' or 'qb'. + * @param int $engineid + */ + protected function store_opaque_servers($urls, $type, $engineid) { + global $DB; + foreach ($urls as $url) { + $server = new stdClass(); + $server->engineid = $engineid; + $server->type = $type; + $server->url = $url; + $DB->insert_record('question_opaque_servers', $server, false); + } + } + + /** + * Delete the definition of an engine from the database. + * @param integer $engineid the id of the engine to delete. + * @return boolean whether the delete succeeded. + */ + public function delete_engine_def($engineid) { + global $DB; + $transaction = $DB->start_delegated_transaction(); + $DB->delete_records('question_opaque_servers', array('engineid' => $engineid)); + $DB->delete_records('question_opaque_engines', array('id' => $engineid)); + $transaction->allow_commit(); + } + + protected function get_possibly_matching_engines($engine) { + global $DB; + + // First we try to get a reasonably accurate guess with SQL - we load + // the id of all engines with the same passkey and which use the first + // questionengine and questionbank (if any). + $tables = array('FROM {question_opaque_engines} e'); + $conditions = array('e.passkey = :passkey'); + $params = array('passkey' => $engine->passkey); + if (!empty($engine->questionengines)) { + $qeurl = reset($engine->questionengines); + $tables[] = "JOIN {question_opaque_servers} qe ON + qe.engineid = e.id AND qe.type = 'qe'"; + $conditions[] = 'qe.url = :qeurl'; + $params['qeurl'] = $qeurl; + } + if (!empty($engine->questionbanks)) { + $qburl = reset($engine->questionbanks); + $tables[] = "JOIN {question_opaque_servers} qb ON + qb.engineid = e.id AND qb.type = 'qb'"; + $conditions[] = 'qb.url = :qburl'; + $params['qburl'] = $qburl; + } + return $DB->get_records_sql_menu(' + SELECT e.id,1 ' . implode(' ', $tables) . ' WHERE ' . + implode(' AND ', $conditions), $params); + } + + /** + * If an engine definition like this one (same passkey and server lists) already exists + * in the database, then return its id, otherwise save this one to the database and + * return the new engine id. + * + * @param object $engine the engine to ensure is in the databse. + * @return integer its id. + */ + public function find_or_create_engineid($engine) { + $possibleengineids = $this->get_possibly_matching_engines($engine); + + // Then we loop through the possibilities loading the full definition and comparing it. + if ($possibleengineids) { + foreach ($possibleengineids as $engineid => $ignored) { + $testengine = $this->load_engine_def($engineid); + $testengine->passkey = addslashes($testengine->passkey); + if ($this->is_same_engine($engine, $testengine)) { + return $engineid; + } + } + } + + return $this->save_engine_def($engine); + } + + /** + * Are these two engine definitions essentially the same (same passkey and server lists)? + * + * @param object $engine1 one engine definition. + * @param object $engine2 another engine definition. + * @return boolean whether they are the same. + */ + public function is_same_engine($engine1, $engine2) { + // Same passkey. + $ans = $engine1->passkey == $engine2->passkey && + // Same question engines. + !array_diff($engine1->questionengines, $engine2->questionengines) && + !array_diff($engine2->questionengines, $engine1->questionengines) && + // Same question banks. + !array_diff($engine1->questionbanks, $engine2->questionbanks) && + !array_diff($engine2->questionbanks, $engine1->questionbanks); + return $ans; + } +} + +/** + * Load the definition of an engine from the database. + * @param integer $engineid the id of the engine to load. + * @return mixed On success, and object with fields id, name, questionengines and questionbanks. + * The last two fields are arrays of URLs. On an error, returns a string to look up in the + * qtype_opaque language file as an error message. + */ +function qtype_opaque_load_engine_def($engineid) { + $manager = new qtype_opaque_engine_manager(); + return $manager->load_engine_def($engineid); +} + +/** + * Save or update an engine definition in the database, and returm the engine id. The definition + * will be created if $engine->id is not set, and updated if it is. + * + * @param object $engine the definition to save. + * @return integer the id of the saved definition. + */ +function qtype_opaque_save_engine_def($engine) { + $manager = new qtype_opaque_engine_manager(); + return $manager->save_engine_def($engine); +} + +/** + * Delete the definition of an engine from the database. + * @param integer $engineid the id of the engine to delete. + * @return boolean whether the delete succeeded. + */ +function qtype_opaque_delete_engine_def($engineid) { + $manager = new qtype_opaque_engine_manager(); + return $manager->delete_engine_def($engineid); +} + +/** + * If an engine definition like this one (same passkey and server lists) already exists + * in the database, then return its id, otherwise save this one to the database and + * return the new engine id. + * + * @param object $engine the engine to ensure is in the databse. + * @return integer its id. + */ +function qtype_opaque_find_or_create_engineid($engine) { + $manager = new qtype_opaque_engine_manager(); + return $manager->find_or_create_engineid($engine); +} + +/** + * @param mixed $engine either an $engine object, or the URL of a particular question engine server. + * @return a soap connection, either to the specific URL give, or to to one of the question engine servers + * of this $engine object picked at random. returns a string to look up in the qtype_opaque + * language file as an error message if a problem arises. + */ +function qtype_opaque_connect($engine) { + if (is_string($engine)) { + $url = $engine; + } else if (!empty($engine->urlused)) { + $url = $engine->urlused; + } else { + $url = $engine->questionengines[array_rand($engine->questionengines)]; + } + $connection = new SoapClient($url . '?wsdl', array('soap_version'=>SOAP_1_1, 'exceptions'=>true)); + if (!is_string($engine)) { + $engine->urlused = $url; + } + return $connection; +} + +/** + * @param mixed $engine either an $engine object, or the URL of a particular question engine server. + * @return some XML, as parsed by xmlize, on success, or a string to look up in the qtype_opaque + * language file as an error message. + */ +function qtype_opaque_get_engine_info($engine) { + $connection = qtype_opaque_connect($engine); + $getengineinforesult = $connection->getEngineInfo(); + return xmlize($getengineinforesult); +} + +/** + * @param mixed $engine either an $engine object, or the URL of a particular question engine server. + * @return The question metadata, as an xmlised array, so, for example, + * $metadata[questionmetadata][@][#][scoring][0][#][marks][0][#] is the maximum possible score for + * this question. + */ +function qtype_opaque_get_question_metadata($engine, $remoteid, $remoteversion) { + $connection = qtype_opaque_connect($engine); + $questionbaseurl = $engine->questionbanks[array_rand($engine->questionbanks)]; + $getmetadataresult = $connection->getQuestionMetadata($remoteid, $remoteversion, $questionbaseurl); + return xmlize($getmetadataresult); +} + +/** + * @param object $engine the engine to connect to. + * @param string $remoteid + * @param string $remoteversion + * @param int $randomseed + * @return mixed the result of the soap call on success, or a string error message on failure. + */ +function qtype_opaque_start_question_session($engine, $remoteid, $remoteversion, $data, $cached_resources) { + $connection = qtype_opaque_connect($engine); + + $questionbaseurl = ''; + if (!empty($engine->questionbanks)) { + $questionbaseurl = $engine->questionbanks[array_rand($engine->questionbanks)]; + } + + $initialparams = array( + 'randomseed' => $data['-_randomseed'], + 'userid' => $data['-_userid'], + 'language' => $data['-_language'], + 'passKey' => qtype_opaque_generate_passkey($engine->passkey, $data['-_userid']), + 'preferredbehaviour' => $data['-_preferredbehaviour'], + ); + + return $connection->start($remoteid, $remoteversion, $questionbaseurl, + array_keys($initialparams), array_values($initialparams), $cached_resources); +} + +function qtype_opaque_process($engine, $questionsessionid, $response) { + $connection = qtype_opaque_connect($engine); + return $connection->process($questionsessionid, array_keys($response), + array_values($response)); +} + +/** + * @param string $questionsessionid the question session to stop. + * @return true on success, or a string error message on failure. + */ +function qtype_opaque_stop_question_session($engine, $questionsessionid) { + $connection = qtype_opaque_connect($engine); + $connection->stop($questionsessionid); + return true; +} + +/** + * Get a step from $qa, as if $pendingstep had already been added at the end + * of the list, if it is not null. + * @param integer $seq + * @param question_attempt $qa + * @param question_attempt_step|null $pendingstep + * @return question_attempt_step + */ +function qtype_opaque_get_step($seq, question_attempt $qa, $pendingstep) { + if ($seq < $qa->get_num_steps()) { + return $qa->get_step($seq); + } + if ($seq == $qa->get_num_steps() && !is_null($pendingstep)) { + return $pendingstep; + } + throw new Exception('Sequence number ' . $seq . ' out of range.'); +} + +/** + * Wrapper round $step->get_submitted_data() to work around an incompatibility + * between OpenMark and the Moodle question engine. + * @param question_attempt_step $step a step. + * @return array approximately $step->get_submitted_data(). + */ +function qtype_opaque_get_submitted_data(question_attempt_step $step) { + // By default, OpenMark radio buttons get the name '_rg', whcih breaks + // one of the assumptions of the qutesion engine, so we have to manually + // include it when doing get_submitted_data. + $response = $step->get_submitted_data(); + if ($step->has_qt_var('_rg')) { + $response['_rg'] = $step->get_qt_var('_rg'); + } + return $response; +} + +/** + * Update the $SESSION->cached_opaque_state to show the current status of $question for state + * $state. + * @param object $question the question + * @param object $state + * @return mixed $SESSION->cached_opaque_state on success, a string error message on failure. + */ +function qtype_opaque_update_state(question_attempt $qa, question_attempt_step $pendingstep = null) { + global $SESSION; + + $question = $qa->get_question(); + $targetseq = $qa->get_num_steps() - 1; + if (!is_null($pendingstep)) { + $targetseq += 1; + } + + if (empty($SESSION->cached_opaque_state) || + empty($SESSION->cached_opaque_state->qaid) || + empty($SESSION->cached_opaque_state->sequencenumber)) { + $cachestatus = 'empty'; + } else if ($SESSION->cached_opaque_state->qaid != $qa->get_database_id() || + $SESSION->cached_opaque_state->sequencenumber > $targetseq) { + if (!empty($SESSION->cached_opaque_state->questionsessionid)) { + $error = qtype_opaque_stop_question_session($SESSION->cached_opaque_state->engine, + $SESSION->cached_opaque_state->questionsessionid); + if (is_string($error)) { + unset($SESSION->cached_opaque_state); + return $error; + } + } + unset($SESSION->cached_opaque_state); + $cachestatus = 'empty'; + } else if ($SESSION->cached_opaque_state->sequencenumber < $targetseq) { + $cachestatus = 'catchup'; + } else { + $cachestatus = 'good'; + } + + $resourcecache = new qtype_opaque_resource_cache($question->engineid, + $question->remoteid, $question->remoteversion); + + if ($cachestatus == 'empty') { + $SESSION->cached_opaque_state = new stdClass; + $opaquestate = $SESSION->cached_opaque_state; + $opaquestate->qaid = $qa->get_database_id(); + $opaquestate->remoteid = $question->remoteid; + $opaquestate->remoteversion = $question->remoteversion; + $opaquestate->engineid = $question->engineid; + $opaquestate->nameprefix = $qa->get_field_prefix(); + $opaquestate->questionended = false; + $opaquestate->sequencenumber = -1; + $opaquestate->resultssequencenumber = -1; + + $engine = qtype_opaque_load_engine_def($question->engineid); + if (is_string($engine)) { + unset($SESSION->cached_opaque_state); + return $engine; + } + $opaquestate->engine = $engine; + + $step = qtype_opaque_get_step(0, $qa, $pendingstep); + $startreturn = qtype_opaque_start_question_session($engine, $question->remoteid, + $question->remoteversion, $step->get_all_data(), $resourcecache->list_cached_resources()); + if (is_string($startreturn)) { + unset($SESSION->cached_opaque_state); + return $startreturn; + } + + qtype_opaque_extract_stuff_from_response($opaquestate, $startreturn, $resourcecache); + $opaquestate->sequencenumber++; + $cachestatus = 'catchup'; + } else { + $opaquestate = $SESSION->cached_opaque_state; + } + + if ($cachestatus == 'catchup') { + if ($opaquestate->sequencenumber >= $targetseq) { + $error = qtype_opaque_stop_question_session($opaquestate->engine, + $opaquestate->questionsessionid); + } + while ($opaquestate->sequencenumber < $targetseq) { + $step = qtype_opaque_get_step($opaquestate->sequencenumber + 1, $qa, $pendingstep); + + $processreturn = qtype_opaque_process($opaquestate->engine, $opaquestate->questionsessionid, + qtype_opaque_get_submitted_data($step)); + if (is_string($processreturn)) { + unset($SESSION->cached_opaque_state); + return $processreturn; + } + + if (!empty($processreturn->results)) { + $opaquestate->resultssequencenumber = $opaquestate->sequencenumber + 1; + $opaquestate->results = $processreturn->results; + } + if ($processreturn->questionEnd) { + $opaquestate->questionended = true; + $opaquestate->sequencenumber = $targetseq; + $opaquestate->xhtml = qtype_opaque_strip_omact_buttons($opaquestate->xhtml); + unset($opaquestate->questionsessionid); + break; + } + qtype_opaque_extract_stuff_from_response($opaquestate, $processreturn, $resourcecache); + + $opaquestate->sequencenumber++; + } + $cachestatus = 'good'; + } + + return $opaquestate; +} + +/** + * File name used to store the CSS of the question, question session id is appended. + */ +function qtype_opaque_stylesheet_filename($questionsessionid) { + return OPAQUE_CSS_FILENAME_PREFIX . $questionsessionid . '.css'; +} + +/** + * Pulls out the fields common to StartResponse and ProcessResponse. + * @param object $opaquestate should be $SESSION->cached_opaque_state, or equivalent. + * @param object $response a StartResponse or ProcessResponse. + * @param object $resourcecache the resource cache for this question. + * @return true on success, or a string error message on failure. + */ +function qtype_opaque_extract_stuff_from_response($opaquestate, $response, $resourcecache) { + global $CFG; + static $replaces; + + if (empty($replaces)) { + $replaces = array( + '%%RESOURCES%%' => '', // Filled in below. + '%%IDPREFIX%%' => '', // Filled in below. + '%%%%' => '%%' + ); + + $strings = array('lTRYAGAIN', 'lGIVEUP', 'lNEXTQUESTION', 'lENTERANSWER', 'lCLEAR'); + foreach ($strings as $string) { + $replaces["%%$string%%"] = get_string($string, 'qtype_opaque'); + } + } + + // Process the XHTML, replacing the strings that need to be replaced. + $xhtml = $response->XHTML; + + $replaces['%%RESOURCES%%'] = $resourcecache->file_url(''); + $replaces['%%IDPREFIX%%'] = $opaquestate->nameprefix; + $xhtml = str_replace(array_keys($replaces), $replaces, $xhtml); + + // TODO this is a nasty hack. Flash uses & as a separator in the FlashVars string, + // so we have to replce the &s with %26s in this one place only. So for now + // do it with a regexp. Longer term, it might be better to changes the file.php urls + // so they don't contain &s. + $xhtml = preg_replace_callback( + '/name="FlashVars" value="TheSound=[^"]+"/', + create_function('$matches', 'return str_replace("&", "%26", $matches[0]);'), + $xhtml); + + // Another hack to take out the next button that most OM questions include, but which does not work in Moodle. + // Actually, we remove any non-disabled buttons, and the following script tag. + // TODO think of a better way to do this. + if ($opaquestate->resultssequencenumber >= 0) { + $xhtml = qtype_opaque_strip_omact_buttons($xhtml); + } + + $opaquestate->xhtml = $xhtml; + + // Process the CSS (only when we have a StartResponse). + if (!empty($response->CSS)) { + $opaquestate->cssfilename = qtype_opaque_stylesheet_filename($response->questionSession); + $resourcecache->cache_file($opaquestate->cssfilename, 'text/css;charset=UTF-8', $response->CSS); + } + + // Process the resources. + $resourcecache->cache_resources($response->resources); + + // Process the other bits. + $opaquestate->progressinfo = $response->progressInfo; + if (!empty($response->questionSession)) { + $opaquestate->questionsessionid = $response->questionSession; + } + + if(!empty($response->head)) { + $opaquestate->headXHTML = $response->head; + } + + return true; +} + +/** + * Strip any buttons, followed by script tags, where the button has an id containing _omact_, and is not disabled. + */ +function qtype_opaque_strip_omact_buttons($xhtml) { + return preg_replace('|])*? id="[^"]*_omact_[^"]*"(?:(?!disabled=)[^>])*?>|', '', $xhtml); +} + +/** + * @param string $secret the secret string for this question engine. + * @param int $userid the id of the user attempting this question. + * @return string the passkey that needs to be sent to the quetion engine to show that + * we are allowed to start a question session for this user. + */ +function qtype_opaque_generate_passkey($secret, $userid) { + return md5($secret . $userid); +} + +/** + * OpenMark relies on certain browser-specific class names to be present in the + * HTML outside the question, in order to apply certian browser-specific layout + * work-arounds. This function re-implements Om's browser sniffing rules. See + * https://openmark.dev.java.net/source/browse/openmark/trunk/src/util/misc/UserAgent.java?view=markup + * @return string class to add to the HTML. + */ +function qtype_opaque_browser_type() { + $useragent = $_SERVER['HTTP_USER_AGENT']; + + // Filter troublemakers + if (strpos($useragent, 'KHTML') !== false) { + return "khtml"; + } + if (strpos($useragent, 'Opera') !== false) { + return "opera"; + } + + // Check version of our two supported browsers + $matches = array(); + if (preg_match('/"^.*rv:(\d+)\\.(\d+)\D.*$"/', $useragent, $matches)) { + return 'gecko-' . $matches[1] . '-' . $matches[2]; + } + if (preg_match('/^.*MSIE (\d+)\\.(\d+)\D.*Windows.*$/', $useragent, $matches)) { + return 'winie-' . $matches[1]; // Major verison only + } + + return ''; +} + +/** + * This class caches the resources belonging a particular question. + * + * There are synchronisation issues if two students are doing the same question at the same time. + */ +class qtype_opaque_resource_cache { + protected $folder; // Path to the folder where resources for this question are cached. + protected $metadatafolder; // Path to the folder where mime types are stored. + protected $baseurl; // initial part of the URL to link to a file in the cache. + + /** + * Create a new qtype_opaque_resource_cache for a particular remote question. + * @param integer $engineid the id of the question engine. + * @param string $remoteid remote question id, as per Opaque spec. + * @param string $remoteversion remote question version, as per Opaque spec. + */ + public function __construct($engineid, $remoteid, $remoteversion) { + global $CFG; + $folderstart = $CFG->dataroot . '/opaqueresources/' . $engineid . '/' . $remoteid . '/' . $remoteversion; + $this->folder = $folderstart . '/files'; + if (!is_dir($this->folder)) { + $this->mkdir_recursive($this->folder); + } + $this->metadatafolder = $folderstart . '/meta'; + if (!is_dir($this->metadatafolder)) { + $this->mkdir_recursive($this->metadatafolder); + } + $this->baseurl = new moodle_url('/question/type/opaque/file.php', array( + 'engineid' => $engineid, 'remoteid' => $remoteid, 'remoteversion' => $remoteversion)); + } + + /** + * @param string $filename the file name. + * @return the full path of a file with the given name. + */ + public function file_path($filename) { + return $this->folder . '/' . $filename; + } + + /** + * @param string $filename the file name. + * @return the full path of a file with the given name. + */ + public function file_meta_path($filename) { + return $this->metadatafolder . '/' . $filename; + } + + /** + * @param string $filename the file name. + * @return the URL to access this file. + */ + public function file_url($filename) { + return new moodle_url($this->baseurl, array('filename' => $filename)); + } + + /** + * @param string $filename the file name. + * @return the URL to access this file. + */ + public function file_mime_type($filename) { + $metapath = $this->file_meta_path($filename); + if (file_exists($metapath)) { + return file_get_contents($metapath); + } + return mimeinfo('type', $filename); + } + + /** + * @param string $filename the name of the file to look for. + * @return true if this named file is in the cache, otherwise false. + */ + public function file_in_cache($filename) { + return file_exists($this->file_path($filename)); + } + + /** + * Serve a file from the cache. + * @param string $filename the file name. + */ + public function serve_file($filename) { + if (!$this->file_in_cache($filename)) { + header('HTTP/1.0 404 Not Found'); + header('Content-Type: text/plain;charset=UTF-8'); + echo 'File not found'; + exit; + } + $mimetype = $this->file_mime_type($filename); + + // Handle If-Modified-Since + $file = $this->file_path($filename); + $filedate = filemtime($file); + $ifmodifiedsince = isset($_SERVER['HTTP_IF_MODIFIED_SINCE']) ? $_SERVER['HTTP_IF_MODIFIED_SINCE'] : false; + if($ifmodifiedsince && strtotime($ifmodifiedsince)>=$filedate) { + header('HTTP/1.0 304 Not Modified'); + exit; + } + header('Last-Modified: '.gmdate('D, d M Y H:i:s',$filedate).' GMT'); + +// // Send expire headers +// $lifetime=4*60*60; +// header('Cache-Control: max-age='.$lifetime); +// header('Expires: '. gmdate('D, d M Y H:i:s', time() + $lifetime) .' GMT'); + + // Type + header('Content-Type: ' . $mimetype); + header('Content-Length: ' . filesize($file)); + + // Output file + session_write_close(); // unlock session during fileserving + readfile($file); + } + + /** + * Store a file in the cache. + * + * @param string $filename the name of the file to cache. + * @param string $mimetype the type of the file to cache. + * @param string $content the contents to write to the file. + */ + public function cache_file($filename, $mimetype, $content) { + file_put_contents($this->file_path($filename), $content); + file_put_contents($this->file_meta_path($filename), $mimetype); + } + + /** + * Add the resources from a particular response to the cache. + * @param array $resources as returned from start or process Opaque methods. + */ + public function cache_resources($resources) { + if(!empty($resources)) { + foreach ($resources as $resource) { + $mimetype = $resource->mimeType; + if (strpos($resource->mimeType, 'text/') === 0 && !empty($resource->encoding)) { + $mimetype .= ';charset=' . $resource->encoding; + } + $this->cache_file($resource->filename, $mimetype, $resource->content); + } + } + } + + /** + * List the resources cached for this question. + * @return array list of resource names. + */ + public function list_cached_resources() { + $filepaths = glob($this->folder . '/*'); + if (!is_array($filepaths)) { + // If an error occurrs, say that we have no files cached. + $filepaths = array(); + } + $pathlen = strlen($this->folder . '/'); + $files = array(); + foreach ($filepaths as &$filepath) { + $file = substr($filepath, $pathlen); + if (strpos($file, OPAQUE_CSS_FILENAME_PREFIX) !== 0) { + $files[] = $file; + } + } + return $files; + } + + /** + * This function exists because mkdir(folder,mode,TRUE) doesn't work on our server. + * Safe to call even if folder already exists (checks) + * @param string $folder Folder to create + * @param int $mode Mode for creation (default 0755) + * @return boolean True if folder (now) exists, false if there was a failure + */ + protected function mkdir_recursive($folder, $mode='') { + if (is_dir($folder)) { + return true; + } + if ($mode == '') { + global $CFG; + $mode = $CFG->directorypermissions; + } + if (!$this->mkdir_recursive(dirname($folder),$mode)) { + return false; + } + return mkdir($folder,$mode); + } +}