From 48d9c17db321bdbd62bd181ffd020cb69e4320aa Mon Sep 17 00:00:00 2001 From: Tim Hunt Date: Fri, 18 Mar 2011 16:39:31 +0000 Subject: [PATCH] MDL-20636 Essay questions can now handle files in the HTML editor. #216 --- question/engine/datalib.php | 44 ++++--- question/engine/lib.php | 164 +++++++++++++++++------ question/type/essay/question.php | 11 +- question/type/essay/renderer.php | 216 ++++++++++++++++++++++++------- question/type/essay/styles.css | 5 +- 5 files changed, 335 insertions(+), 105 deletions(-) diff --git a/question/engine/datalib.php b/question/engine/datalib.php index 5013b35f18e..e96175749ae 100644 --- a/question/engine/datalib.php +++ b/question/engine/datalib.php @@ -119,15 +119,15 @@ class question_engine_data_mapper { $record->id = $this->db->insert_record('question_attempt_steps', $record); foreach ($step->get_all_data() as $name => $value) { + if ($value instanceof question_file_saver) { + $value->save_files($record->id, $context); + } + $data = new stdClass(); $data->attemptstepid = $record->id; $data->name = $name; $data->value = $value; $this->db->insert_record('question_attempt_step_data', $data, false); - - if ($value instanceof question_file_saver) { - $value->save_files($record->id, $context); - } } } @@ -1022,24 +1022,27 @@ class question_file_saver { * @param string $component the component for the file area to save into. * @param string $filearea the name of the file area to save into. */ - public function __construct($draftitemid, $component, $filearea) { + public function __construct($draftitemid, $component, $filearea, $text = null) { $this->draftitemid = $draftitemid; $this->component = $component; $this->filearea = $filearea; + $this->value = $this->compute_value($draftitemid, $text); } - protected function get_value() { + /** + * Compute the value that should be stored in the question_attempt_step_data + * table. Contains a hash that (almost) uniquely encodes all the files. + * @param int $draftitemid the draft file area itemid. + * @param string $text optional content containing file links. + */ + protected function compute_value($draftitemid, $text) { global $USER; - if (!is_null($this->value)) { - return $this->value; - } - $fs = get_file_storage(); $usercontext = get_context_instance(CONTEXT_USER, $USER->id); $files = $fs->get_area_files($usercontext->id, 'user', 'draft', - $this->draftitemid, 'sortorder, filepath, filename', false); + $draftitemid, 'sortorder, filepath, filename', false); $string = ''; foreach ($files as $file) { @@ -1048,16 +1051,27 @@ class question_file_saver { } if ($string) { - $this->value = md5($string); + $hash = md5($string); } else { - $this->value = ''; + $hash = ''; } - return $this->value; + if (is_null($text)) { + return $hash; + } + + // We add the file hash so a simple string comparison will say if the + // files have been changed. First strip off any existing file hash. + $text = preg_replace('/\s*\s*$/', '', $text); + $text = file_rewrite_urls_to_pluginfile($text, $draftitemid); + if ($hash) { + $text .= ''; + } + return $text; } public function __toString() { - return $this->get_value(); + return $this->value; } /** diff --git a/question/engine/lib.php b/question/engine/lib.php index c5176607676..141edfc30ce 100644 --- a/question/engine/lib.php +++ b/question/engine/lib.php @@ -1347,6 +1347,12 @@ class question_attempt { */ const PARAM_FILES = 'paramfiles'; + /** + * @var string special value to indicate a response variable that is uploaded + * files. + */ + const PARAM_CLEANHTML_FILES = 'paramcleanhtmlfiles'; + /** @var integer if this attempts is stored in the question_attempts table, the id of that row. */ protected $id = null; @@ -1661,6 +1667,21 @@ class question_attempt { return $default; } + /** + * Get the last step with a particular question type varialbe set. + * @param string $name the name of the variable to get. + * @return question_attempt_step the last step, or a step with no variables + * if there was not a real step. + */ + public function get_last_step_with_qt_var($name) { + foreach ($this->get_reverse_step_iterator() as $step) { + if ($step->has_qt_var($name)) { + return $step; + } + } + return new question_attempt_step_read_only(); + } + /** * Get the latest value of a particular question type variable. That is, get * the value from the latest step that has it set. Return null if it is not @@ -1672,12 +1693,12 @@ class question_attempt { * @return mixed string value, or $default if it has never been set. */ public function get_last_qt_var($name, $default = null) { - foreach ($this->get_reverse_step_iterator() as $step) { - if ($step->has_qt_var($name)) { - return $step->get_qt_var($name); - } + $step = $this->get_last_step_with_qt_var($name); + if ($step->has_qt_var($name)) { + return $step->get_qt_var($name); + } else { + return $default; } - return $default; } /** @@ -1890,6 +1911,15 @@ class question_attempt { return $this->behaviour->summarise_action($step); } + /** + * Helper function used by {@link rewrite_pluginfile_urls()} and + * {@link rewrite_response_pluginfile_urls()}. + * @return array ids that need to go into the file paths. + */ + protected function extra_file_path_components() { + return array($this->get_usage_id(), $this->get_slot()); + } + /** * Calls {@link question_rewrite_question_urls()} with appropriate parameters * for content belonging to this question. @@ -1897,11 +1927,28 @@ class question_attempt { * @param string $component the component name (normally 'question' or 'qtype_...') * @param string $filearea the name of the file area. * @param int $itemid the item id. + * @return srting the content with the URLs rewritten. */ public function rewrite_pluginfile_urls($text, $component, $filearea, $itemid) { - return question_rewrite_question_urls($text, - 'pluginfile.php', $this->question->contextid, $component, $filearea, - array($this->get_usage_id(), $this->get_slot()), $itemid); + return question_rewrite_question_urls($text, 'pluginfile.php', + $this->question->contextid, $component, $filearea, + $this->extra_file_path_components(), $itemid); + } + + /** + * Calls {@link question_rewrite_question_urls()} with appropriate parameters + * for content belonging to responses to this question. + * + * @param string $text the text to update the URLs in. + * @param int $contextid the id of the context the quba belongs to. + * @param string $name the variable name the files belong to. + * @param question_attempt_step $step the step the response is coming from. + * @return srting the content with the URLs rewritten. + */ + public function rewrite_response_pluginfile_urls($text, $contextid, $name, + question_attempt_step $step) { + return $step->rewrite_response_pluginfile_urls($text, $contextid, $name, + $this->extra_file_path_components()); } /** @@ -2043,61 +2090,66 @@ class question_attempt { * {@link optional_param()}, except that the results is returned without * slashes. * @param string $name the paramter name. - * @param int $type one of the PARAM_... constants. + * @param int $type one of the standard PARAM_... constants, or one of the + * special extra constands defined by this class. * @param array $postdata (optional, only inteded for testing use) take the * data from this array, instead of from $_POST. * @return mixed the requested value. */ public function get_submitted_var($name, $type, $postdata = null) { - // Special case to work around PARAM_NUMBER converting '' to 0. - if ($type == self::PARAM_MARK) { - $mark = $this->get_submitted_var($name, PARAM_RAW_TRIMMED, $postdata); - if ($mark === '') { - return $mark; - } else { - return $this->get_submitted_var($name, PARAM_NUMBER, $postdata); - } - } + switch ($type) { + case self::PARAM_MARK: + // Special case to work around PARAM_NUMBER converting '' to 0. + $mark = $this->get_submitted_var($name, PARAM_RAW_TRIMMED, $postdata); + if ($mark === '') { + return $mark; + } else { + return $this->get_submitted_var($name, PARAM_NUMBER, $postdata); + } - if ($type == self::PARAM_FILES) { - return $this->process_response_files($name, $postdata); - } + case self::PARAM_FILES: + return $this->process_response_files($name, $name, $postdata); - if (is_null($postdata)) { - $var = optional_param($name, null, $type); - } else if (array_key_exists($name, $postdata)) { - $var = clean_param($postdata[$name], $type); - } else { - $var = null; - } + case self::PARAM_CLEANHTML_FILES: + $var = $this->get_submitted_var($name, PARAM_CLEANHTML, $postdata); + return $this->process_response_files($name, $name . ':itemid', $postdata, $var); - if (is_string($var)) { - $var = $var; - } + default: + if (is_null($postdata)) { + $var = optional_param($name, null, $type); + } else if (array_key_exists($name, $postdata)) { + $var = clean_param($postdata[$name], $type); + } else { + $var = null; + } - return $var; + return $var; + } } /** * Handle a submitted variable representing uploaded files. * @param string $name the field name. + * @param string $draftidname the field name holding the draft file area id. * @param array $postdata (optional, only inteded for testing use) take the * data from this array, instead of from $_POST. At the moment, this * behaves as if there were no files. + * @param string $text optional reponse text. + * @return question_file_saver that can be used to save the files later. */ - protected function process_response_files($name, $postdata = null) { + protected function process_response_files($name, $draftidname, $postdata = null, $text = null) { if ($postdata) { // There can be no files with test data (at the moment). return null; } - $draftitemid = file_get_submitted_draft_itemid($name); + $draftitemid = file_get_submitted_draft_itemid($draftidname); if (!$draftitemid) { return null; } return new question_file_saver($draftitemid, 'question', 'response_' . - str_replace($this->get_field_prefix(), '', $name)); + str_replace($this->get_field_prefix(), '', $name), $text); } /** @@ -2612,10 +2664,6 @@ class question_attempt_step { } } - public function get_id() { - return $this->id; // TODO get rid of this. - } - /** @return question_state The state after this step. */ public function get_state() { return $this->state; @@ -2719,12 +2767,44 @@ class question_attempt_step { * @return int the draft itemid. */ public function prepare_response_files_draft_itemid($name, $contextid) { - $draftid = 0; // Will be filled in by file_prepare_draft_area. - file_prepare_draft_area($draftid, $contextid, 'question', - 'response_' . $name, $this->id); + list($draftid, $notused) = $this->prepare_response_files_draft_itemid_with_text( + $name, $contextid, null); return $draftid; } + /** + * Prepare a draft file are for the files belonging the a response variable + * of this step, while rewriting the URLs in some text. + * + * @param string $name the variable name the files belong to. + * @param int $contextid the id of the context the quba belongs to. + * @param string $text the text to update the URLs in. + * @return array(int, string) the draft itemid and the text with URLs rewritten. + */ + public function prepare_response_files_draft_itemid_with_text($name, $contextid, $text) { + $draftid = 0; // Will be filled in by file_prepare_draft_area. + $newtext = file_prepare_draft_area($draftid, $contextid, 'question', + 'response_' . $name, $this->id, null, $text); + return array($draftid, $newtext); + } + + /** + * Rewrite the @@PLUGINFILE@@ tokens in a response variable from this step + * that contains links to file. Normally you should probably call + * {@link question_attempt::rewrite_response_pluginfile_urls()} instead of + * calling this method directly. + * + * @param string $text the text to update the URLs in. + * @param int $contextid the id of the context the quba belongs to. + * @param string $name the variable name the files belong to. + * @param array $extra extra file path components. + * @return string the rewritten text. + */ + public function rewrite_response_pluginfile_urls($text, $contextid, $name, $extras) { + return question_rewrite_question_urls($text, 'pluginfile.php', $contextid, + 'question', 'response_' . $name, $extras, $this->id); + } + /** * Get all the question type variables. * @param array name => value pairs. diff --git a/question/type/essay/question.php b/question/type/essay/question.php index 3f730cd9d01..92d2070c32d 100644 --- a/question/type/essay/question.php +++ b/question/type/essay/question.php @@ -55,7 +55,12 @@ class qtype_essay_question extends question_with_responses { } public function get_expected_data() { - $expecteddata = array('answer' => PARAM_CLEANHTML, 'answerformat' => PARAM_FORMAT); + if ($this->responseformat == 'editorfilepicker') { + $expecteddata = array('answer' => question_attempt::PARAM_CLEANHTML_FILES); + } else { + $expecteddata = array('answer' => PARAM_CLEANHTML); + } + $expecteddata['answerformat'] = PARAM_FORMAT; if ($this->attachments != 0) { $expecteddata['attachments'] = question_attempt::PARAM_FILES; } @@ -92,6 +97,10 @@ class qtype_essay_question extends question_with_responses { // Response attachments visible if the question has them. return $this->attachments != 0; + } else if ($component == 'question' && $filearea == 'response_answer') { + // Response attachments visible if the question has them. + return $this->responseformat === 'editorfilepicker'; + } else if ($component == 'qtype_essay' && $filearea == 'graderinfo') { return $options->manualcomment; diff --git a/question/type/essay/renderer.php b/question/type/essay/renderer.php index c2d6fb81a57..684a30ce2b1 100644 --- a/question/type/essay/renderer.php +++ b/question/type/essay/renderer.php @@ -42,17 +42,14 @@ class qtype_essay_renderer extends qtype_renderer { $responseoutput = $question->get_format_renderer($this->page); // Answer field. - $inputname = $qa->get_qt_field_name('answer'); - $response = $qa->get_last_qt_var('answer', ''); - $responseformat = $qa->get_last_qt_var('answerformat', FORMAT_HTML); + $step = $qa->get_last_step_with_qt_var('answer'); if (empty($options->readonly)) { - $answer = $responseoutput->response_area_input($inputname, - $response, $responseformat, $question->responsefieldlines, - $options->context); + $answer = $responseoutput->response_area_input('answer', $qa, + $step, $question->responsefieldlines, $options->context); } else { - $answer = $responseoutput->response_area_read_only($inputname, - $response, $responseformat, $question->responsefieldlines); + $answer = $responseoutput->response_area_read_only('answer', $qa, + $step, $question->responsefieldlines, $options->context); } $files = ''; @@ -114,6 +111,9 @@ class qtype_essay_renderer extends qtype_renderer { 'attachments', $options->context->id); $pickeroptions->context = $options->context; + $pickeroptions->itemid = $qa->prepare_response_files_draft_itemid( + 'attachments', $options->context->id); + return form_filemanager_render($pickeroptions) . html_writer::empty_tag( 'input', array('type' => 'hidden', 'name' => $qa->get_qt_field_name('attachments'), 'value' => $pickeroptions->itemid)); @@ -142,21 +142,32 @@ class qtype_essay_renderer extends qtype_renderer { abstract class qtype_essay_format_renderer_base extends plugin_renderer_base { /** * Render the students respone when the question is in read-only mode. - * @param string $inputname the field name to use for this input. - * @param string $response the student's current response. + * @param string $name the variable name this input edits. + * @param question_attempt $qa the question attempt being display. + * @param question_attempt_step $step the current step. * @param int $lines approximate size of input box to display. + * @param object $context the context teh output belongs to. + * @return string html to display the response. */ - public abstract function response_area_read_only($inputname, $response, - $responseformat, $lines); + public abstract function response_area_read_only($name, question_attempt $qa, + question_attempt_step $step, $lines, $context); /** * Render the students respone when the question is in read-only mode. - * @param string $inputname the field name to use for this input. - * @param string $response the student's current response. + * @param string $name the variable name this input edits. + * @param question_attempt $qa the question attempt being display. + * @param question_attempt_step $step the current step. * @param int $lines approximate size of input box to display. + * @param object $context the context teh output belongs to. + * @return string html to display the response for editing. + */ + public abstract function response_area_input($name, question_attempt $qa, + question_attempt_step $step, $lines, $context); + + /** + * @return string specific class name to add to the input element. */ - public abstract function response_area_input($inputname, $response, - $responseformat, $lines, $contex); + protected abstract function class_name(); } @@ -168,18 +179,21 @@ abstract class qtype_essay_format_renderer_base extends plugin_renderer_base { * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class qtype_essay_format_editor_renderer extends plugin_renderer_base { - public function response_area_read_only($inputname, $response, $responseformat, $lines) { - $formatoptions = new stdClass(); - $formatoptions->para = false; - $response = $this->rewrite_pluginfile_urls($response); - return html_writer::tag('div', format_text($response, $responseformat, $formatoptions), - array('class' => 'qtype_essay_editor qtype_essay_response')); + protected function class_name() { + return 'qtype_essay_editor'; } - public function response_area_input($inputname, $response, $responseformat, $lines, $context) { + public function response_area_read_only($name, $qa, $step, $lines, $context) { + return html_writer::tag('div', $this->prepare_response($name, $qa, $step, $context), + array('class' => $this->class_name() . ' qtype_essay_response')); + } + + public function response_area_input($name, $qa, $step, $lines, $context) { global $CFG, $PAGE; require_once($CFG->dirroot.'/repository/lib.php'); + $inputname = $qa->get_qt_field_name($name); + $responseformat = $step->get_qt_var($name . 'format'); $id = $inputname . '_id'; $editor = editors_get_preferred_editor($responseformat); @@ -189,13 +203,17 @@ class qtype_essay_format_editor_renderer extends plugin_renderer_base { $formats[$fid] = $strformats[$fid]; } + list($draftitemid, $reponse) = $this->prepare_response_for_editing( + $name, $step, $context); + $editor->use_editor($id, $this->get_editor_options($context), - $this->get_filepicker_options()); + $this->get_filepicker_options($context, $draftitemid)); $output = ''; - $output .= html_writer::start_tag('div'); + $output .= html_writer::start_tag('div', array('class' => + $this->class_name() . ' qtype_essay_response')); - $output .= html_writer::tag('div', html_writer::tag('textarea', s($response), + $output .= html_writer::tag('div', html_writer::tag('textarea', s($reponse), array('id' => $id, 'name' => $inputname, 'rows' => $lines))); $output .= html_writer::start_tag('div'); @@ -209,28 +227,46 @@ class qtype_essay_format_editor_renderer extends plugin_renderer_base { } $output .= html_writer::end_tag('div'); - $output .= $this->nonjs_filepicker(); + $output .= $this->filepicker_html($inputname, $draftitemid); $output .= html_writer::end_tag('div'); return $output; } /** - * @param string $response the student's response. - * @return string the response with file URLs processed. + * Prepare the response for read-only display. + * @param string $name the variable name this input edits. + * @param question_attempt $qa the question attempt being display. + * @param question_attempt_step $step the current step. + * @param object $context the context the attempt belongs to. + * @return string the response prepared for display. */ - protected function rewrite_pluginfile_urls($response) { - return $response; + protected function prepare_response($name, question_attempt $qa, + question_attempt_step $step, $context) { + if (!$step->has_qt_var($name)) { + return ''; + } + + $formatoptions = new stdClass(); + $formatoptions->para = false; + return format_text($step->get_qt_var($name), $step->get_qt_var($name . 'format'), + $formatoptions); } /** - * @return array filepicker options for the editor. + * Prepare the response for editing. + * @param string $name the variable name this input edits. + * @param question_attempt_step $step the current step. + * @param object $context the context the attempt belongs to. + * @return string the response prepared for display. */ - protected function get_filepicker_options() { - return array(); + protected function prepare_response_for_editing($name, + question_attempt_step $step, $context) { + return array(0, $step->get_qt_var($name)); } /** + * @param object $context the context the attempt belongs to. * @return array options for the editor. */ protected function get_editor_options($context) { @@ -238,9 +274,20 @@ class qtype_essay_format_editor_renderer extends plugin_renderer_base { } /** - * Extra output for the filepicker, if used. + * @param object $context the context the attempt belongs to. + * @param int $draftitemid draft item id. + * @return array filepicker options for the editor. + */ + protected function get_filepicker_options($context, $draftitemid) { + return array(); + } + + /** + * @param string $inputname input field name. + * @param int $draftitemid draft file area itemid. + * @return string HTML for the filepicker, if used. */ - protected function nonjs_filepicker() { + protected function filepicker_html($inputname, $draftitemid) { return ''; } } @@ -254,17 +301,93 @@ class qtype_essay_format_editor_renderer extends plugin_renderer_base { * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class qtype_essay_format_editorfilepicker_renderer extends qtype_essay_format_editor_renderer { + protected function class_name() { + return 'qtype_essay_editorfilepicker'; + } + + protected function prepare_response($name, question_attempt $qa, + question_attempt_step $step, $context) { + if (!$step->has_qt_var($name)) { + return ''; + } - protected function rewrite_pluginfile_urls($response) { - return $response; + $formatoptions = new stdClass(); + $formatoptions->para = false; + $text = $qa->rewrite_response_pluginfile_urls($step->get_qt_var($name), + $context->id, 'answer', $step); + return format_text($text, $step->get_qt_var($name . 'format'), $formatoptions); } - protected function get_filepicker_options() { - return array(); + protected function prepare_response_for_editing($name, + question_attempt_step $step, $context) { + return $step->prepare_response_files_draft_itemid_with_text( + $name, $context->id, $step->get_qt_var($name)); } - protected function nonjs_filepicker() { - return ''; + protected function get_editor_options($context) { + return array( + 'subdirs' => 0, + 'maxbytes' => 0, + 'maxfiles' => -1, + 'context' => $context, + 'noclean' => 0, + 'trusttext'=>0 + ); + } + + /** + * Get the options required to configure the filepicker for one of the editor + * toolbar buttons. + * @param mixed $acceptedtypes array of types of '*'. + * @param int $draftitemid the draft area item id. + * @param object $context the context. + * @return object the required options. + */ + protected function specific_filepicker_options($acceptedtypes, $draftitemid, $context) { + $filepickeroptions = new stdClass(); + $filepickeroptions->accepted_types = $acceptedtypes; + $filepickeroptions->return_types = FILE_INTERNAL | FILE_EXTERNAL; + $filepickeroptions->context = $context; + $filepickeroptions->env = 'filepicker'; + + $options = initialise_filepicker($filepickeroptions); + $options->context = $context; + $options->client_id = uniqid(); + $options->env = 'editor'; + $options->itemid = $draftitemid; + + return $options; + } + + protected function get_filepicker_options($context, $draftitemid) { + global $CFG; + + return array( + 'image' => $this->specific_filepicker_options(array('image'), + $draftitemid, $context), + 'media' => $this->specific_filepicker_options(array('video', 'audio'), + $draftitemid, $context), + 'link' => $this->specific_filepicker_options('*', + $draftitemid, $context), + ); + } + + protected function filepicker_html($inputname, $draftitemid) { + $nonjspickerurl = new moodle_url('/repository/draftfiles_manager.php', array( + 'action' => 'browse', + 'env' => 'editor', + 'itemid' => $draftitemid, + 'subdirs' => false, + 'maxfiles' => -1, + 'sesskey' => sesskey(), + )); + + return html_writer::empty_tag('input', array('type' => 'hidden', + 'name' => $inputname . ':itemid', 'value' => $draftitemid)) . + html_writer::tag('noscript', html_writer::tag('div', + html_writer::tag('object', '', array('type' => 'text/html', + 'data' => $nonjspickerurl, 'height' => 160, 'width' => 600, + 'style' => 'border: 1px solid #000;')))); } } @@ -290,12 +413,13 @@ class qtype_essay_format_plain_renderer extends plugin_renderer_base { return 'qtype_essay_plain'; } - public function response_area_read_only($inputname, $response, $responseformat, $lines) { - return $this->textarea($response, $lines, array('readonly' => 'readonly')); + public function response_area_read_only($name, $qa, $step, $lines, $context) { + return $this->textarea($step->get_qt_var($name), $lines, array('readonly' => 'readonly')); } - public function response_area_input($inputname, $response, $responseformat, $lines, $contex) { - return $this->textarea($response, $lines, array('name' => $inputname)) . + public function response_area_input($name, $qa, $step, $lines, $context) { + $inputname = $qa->get_qt_field_name($name); + return $this->textarea($step->get_qt_var($name), $lines, array('name' => $inputname)) . html_writer::empty_tag('input', array('type' => 'hidden', 'name' => $inputname . 'format', 'value' => FORMAT_PLAIN)); } diff --git a/question/type/essay/styles.css b/question/type/essay/styles.css index cf1c8c667c8..d7457fa7ad1 100644 --- a/question/type/essay/styles.css +++ b/question/type/essay/styles.css @@ -9,7 +9,10 @@ white-space: pre; font-family: Andale Mono, Monaco, Courier New, DejaVu Sans Mono, monospace; } -.que.essay .qtype_essay_response.qtype_essay_editor { +.que.essay .qtype_essay_response { background-color: white; min-height: 3em; } +.que.essay div.qtype_essay_response textarea { + width: 100%; +} -- 2.43.0