diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 15bf6dfd..28d6c370 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,7 +3,7 @@ on: [push, pull_request] jobs: test: - runs-on: 'ubuntu-latest' + runs-on: ubuntu-22.04 services: postgres: @@ -14,14 +14,14 @@ jobs: ports: - 5432:5432 options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 3 + mariadb: - image: mariadb:10.6 + image: mariadb:10 env: MYSQL_USER: 'root' MYSQL_ALLOW_EMPTY_PASSWORD: "true" MYSQL_CHARACTER_SET_SERVER: "utf8mb4" MYSQL_COLLATION_SERVER: "utf8mb4_unicode_ci" - ports: - 3306:3306 options: --health-cmd="mysqladmin ping" --health-interval 10s --health-timeout 5s --health-retries 3 @@ -45,7 +45,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: path: plugin @@ -60,12 +60,12 @@ jobs: - name: Deploy moodle-plugin-ci run: | - composer create-project -n --no-dev --prefer-dist moodlehq/moodle-plugin-ci ci ^3 - # Add dirs to $PATH + composer create-project -n --no-dev --prefer-dist moodlehq/moodle-plugin-ci ci ^4 echo $(cd ci/bin; pwd) >> $GITHUB_PATH echo $(cd ci/vendor/bin; pwd) >> $GITHUB_PATH - # PHPUnit depends on en_AU.UTF-8 locale sudo locale-gen en_AU.UTF-8 + # Install nvm. + curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash - name: Install Moodle # Need explicit IP to stop mysql client fail on attempt to use unix socket. @@ -92,7 +92,7 @@ jobs: - name: Moodle Code Checker if: ${{ always() }} - run: moodle-plugin-ci codechecker || true + run: moodle-plugin-ci codechecker - name: Moodle PHPDoc Checker if: ${{ always() }} diff --git a/classes/feedback_section_form.php b/classes/feedback_section_form.php index e566d04c..9a173fd7 100644 --- a/classes/feedback_section_form.php +++ b/classes/feedback_section_form.php @@ -21,6 +21,7 @@ require_once($CFG->libdir . '/formslib.php'); require_once($CFG->dirroot.'/mod/questionnaire/lib.php'); +#[\AllowDynamicProperties] /** * Print the form to add or edit a questionnaire-instance * @@ -38,6 +39,9 @@ class feedback_section_form extends \moodleform { */ public $context; + /** @var int $sid The section id. */ + public $sid; + /** * Form definition. */ diff --git a/classes/file_storage.php b/classes/file_storage.php index 329e9dff..58a5dc10 100644 --- a/classes/file_storage.php +++ b/classes/file_storage.php @@ -17,7 +17,7 @@ namespace mod_questionnaire; /** - * Defines the file stoeage class for questionnaire. + * Defines the file storage class for questionnaire. * @package mod_questionnaire * @copyright 2020 onwards Mike Churchward (mike.churchward@poetopensource.org) * @author Mike Churchward diff --git a/classes/output/renderer.php b/classes/output/renderer.php index 5bc029b3..7febfda6 100755 --- a/classes/output/renderer.php +++ b/classes/output/renderer.php @@ -378,6 +378,7 @@ public function print_preview_formend($url, $submitstr, $resetstr) { $output .= \html_writer::start_tag('div'); $output .= \html_writer::empty_tag('input', ['type' => 'submit', 'name' => 'submit', 'value' => $submitstr, 'class' => 'btn btn-primary']); + $output .= \html_writer::empty_tag('input', ['type' => 'hidden', 'name' => 'sesskey', 'value' => sesskey()]); $output .= ' '; $output .= \html_writer::tag('a', $resetstr, ['href' => $url, 'class' => 'btn btn-secondary mr-1']); $output .= \html_writer::end_tag('div') . "\n"; diff --git a/classes/question/file.php b/classes/question/file.php new file mode 100644 index 00000000..a77ddda8 --- /dev/null +++ b/classes/question/file.php @@ -0,0 +1,237 @@ +. +namespace mod_questionnaire\question; +use core_media_manager; +use form_filemanager; +use mod_questionnaire\responsetype\response\response; +use moodle_url; +use MoodleQuickForm; + +/** + * This file contains the parent class for text question types. + * + * @author Laurent David + * @author Martin Cornu-Mansuy + * @copyright 2023 onward CALL Learning + * @license http://www.gnu.org/copyleft/gpl.html GNU Public License + * @package mod_questionnaire + */ +class file extends question { + + /** + * Get name. + * + * @return string + */ + public function helpname() { + return 'file'; + } + + /** + * Override and return a form template if provided. Output of question_survey_display is iterpreted based on this. + * + * @return boolean | string + */ + public function question_template() { + return false; + } + + /** + * Override and return a response template if provided. Output of response_survey_display is iterpreted based on this. + * + * @return boolean | string + */ + public function response_template() { + return false; + } + + /** + * Get response class. + * + * @return object|string + */ + protected function responseclass() { + return '\\mod_questionnaire\\responsetype\\file'; + } + + /** + * Survey display output. + * + * @param response $response + * @param object $descendantsdata + * @param bool $blankquestionnaire + * @return string + */ + protected function question_survey_display($response, $descendantsdata, $blankquestionnaire = false) { + global $CFG, $PAGE; + require_once($CFG->libdir . '/filelib.php'); + + $elname = 'q' . $this->id; + // Make sure there is a response, fetch the draft id from the original request. + if (isset($response->answers[$this->id]) && !empty($response->answers[$this->id]) && isset($_REQUEST[$elname . 'draft'])) { + $draftitemid = (int)$_REQUEST[$elname . 'draft']; + } else { + $draftitemid = file_get_submitted_draft_itemid($elname); + } + if ($draftitemid > 0) { + file_prepare_draft_area($draftitemid, $this->context->id, + 'mod_questionnaire', 'file', $this->id, self::get_file_manager_option()); + } else { + $draftitemid = file_get_unused_draft_itemid(); + } + // Filemanager form element implementation is far from optimal, we need to rework this if we ever fix it... + require_once("$CFG->dirroot/lib/form/filemanager.php"); + + $options = array_merge(self::get_file_manager_option(), [ + 'client_id' => uniqid(), + 'itemid' => $draftitemid, + 'target' => $this->id, + 'name' => $elname, + ]); + $fm = new form_filemanager((object)$options); + $output = $PAGE->get_renderer('core', 'files'); + + $html = '
' . + $output->render($fm) . + '' . + '' . + '
'; + + return $html; + } + + /** + * Check question's form data for complete response. + * @param \stdClass $responsedata The data entered into the response. + * @return bool + */ + public function response_complete($responsedata) { + $answered = false; + // If $responsedata is a response object, look through the answers. + if (is_a($responsedata, 'mod_questionnaire\responsetype\response\response') && + isset($responsedata->answers[$this->id]) && !empty($responsedata->answers[$this->id]) + ) { + $answer = reset($responsedata->answers[$this->id]); + $answered = ((int)$answer->value > 0); + } else if (isset($responsedata->{'q'.$this->id})) { + // If $responsedata is webform data, check that it is not empty. + $draftitemid = (int)$responsedata->{'q' . $this->id}; + if ($draftitemid > 0) { + $info = file_get_draft_area_info($draftitemid); + $answered = $info['filecount'] > 0; + } + } + return !($this->required() && ($this->deleted == 'n') && !$answered); + } + + /** + * Get file manager options + * + * @return array + */ + public static function get_file_manager_option() { + return [ + 'mainfile' => '', + 'subdirs' => false, + 'accepted_types' => ['image', '.pdf'], + 'maxfiles' => 1, + ]; + } + + /** + * Response display output. + * + * @param \stdClass $data + * @return string + */ + protected function response_survey_display($data) { + global $PAGE, $CFG; + require_once($CFG->libdir . '/filelib.php'); + require_once($CFG->libdir . '/resourcelib.php'); + if (isset($data->answers[$this->id])) { + $answer = reset($data->answers[$this->id]); + } else { + return ''; + } + $fs = get_file_storage(); + $file = $fs->get_file_by_id($answer->value); + $code = ''; + + if ($file) { + // There is a file. + $moodleurl = moodle_url::make_pluginfile_url( + $file->get_contextid(), + $file->get_component(), + $file->get_filearea(), + $file->get_itemid(), + $file->get_filepath(), + $file->get_filename() + ); + + $mimetype = $file->get_mimetype(); + $title = ''; + + $mediamanager = core_media_manager::instance($PAGE); + $embedoptions = array( + core_media_manager::OPTION_TRUSTED => true, + core_media_manager::OPTION_BLOCK => true, + ); + + if (file_mimetype_in_typegroup($mimetype, 'web_image')) { // It's an image. + $code = resourcelib_embed_image($moodleurl->out(), $title); + + } else if ($mimetype === 'application/pdf') { + // PDF document. + $code = resourcelib_embed_pdf($moodleurl->out(), $title, get_string('view')); + + } else if ($mediamanager->can_embed_url($moodleurl, $embedoptions)) { + // Media (audio/video) file. + $code = $mediamanager->embed_url($moodleurl, $title, 0, 0, $embedoptions); + + } else { + // We need a way to discover if we are loading remote docs inside an iframe. + $moodleurl->param('embed', 1); + + // Anything else - just try object tag enlarged as much as possible. + $code = resourcelib_embed_general($moodleurl, $title, get_string('view'), $mimetype); + } + } + return '
' . $code . '
'; + } + + /** + * Add the length element as hidden. + * + * @param \MoodleQuickForm $mform + * @param string $helpname + * @return \MoodleQuickForm + */ + protected function form_length(MoodleQuickForm $mform, $helpname = '') { + return question::form_length_hidden($mform); + } + + /** + * Add the precise element as hidden. + * + * @param \MoodleQuickForm $mform + * @param string $helpname + * @return \MoodleQuickForm + */ + protected function form_precise(MoodleQuickForm $mform, $helpname = '') { + return question::form_precise_hidden($mform); + } + +} diff --git a/classes/question/numerical.php b/classes/question/numerical.php index d86bbedb..994e000b 100644 --- a/classes/question/numerical.php +++ b/classes/question/numerical.php @@ -83,7 +83,7 @@ protected function question_survey_display($response, $descendantsdata, $blankqu // Numeric. $questiontags = new \stdClass(); $precision = $this->precise; - $a = new \StdClass(); + $a = new \stdClass(); if (isset($response->answers[$this->id][0])) { $mynumber = $response->answers[$this->id][0]->value; if ($mynumber != '') { diff --git a/classes/question/question.php b/classes/question/question.php index 7b4594b5..9b0ac6b3 100644 --- a/classes/question/question.php +++ b/classes/question/question.php @@ -42,6 +42,7 @@ define('QUESDATE', 9); define('QUESNUMERIC', 10); define('QUESSLIDER', 11); +define('QUESFILE', 12); define('QUESPAGEBREAK', 99); define('QUESSECTIONTEXT', 100); @@ -119,6 +120,7 @@ abstract class question { QUESDROP => 'drop', QUESRATE => 'rate', QUESDATE => 'date', + QUESFILE => 'file', QUESNUMERIC => 'numerical', QUESPAGEBREAK => 'pagebreak', QUESSECTIONTEXT => 'sectiontext', diff --git a/classes/responsetype/file.php b/classes/responsetype/file.php new file mode 100644 index 00000000..0af324c7 --- /dev/null +++ b/classes/responsetype/file.php @@ -0,0 +1,421 @@ +. +namespace mod_questionnaire\responsetype; + +use mod_questionnaire\db\bulk_sql_config; +use moodle_url; + +/** + * Class for text response types. + * + * @author Laurent David + * @author Martin Cornu-Mansuy + * @copyright 2023 onward CALL Learning + * @license http://www.gnu.org/copyleft/gpl.html GNU Public License + * @package mod_questionnaire + */ +class file extends responsetype { + /** + * Provide an array of answer objects from web form data for the question. + * + * @param \stdClass $responsedata All of the responsedata as an object. + * @param \mod_questionnaire\question\question $question + * @return array \mod_questionnaire\responsetype\answer\answer An array of answer objects. + */ + public static function answers_from_webform($responsedata, $question) { + $answers = []; + if (isset($responsedata->{'q' . $question->id}) && (strlen($responsedata->{'q' . $question->id}) > 0)) { + $val = $responsedata->{'q' . $question->id}; + $record = new \stdClass(); + $record->responseid = $responsedata->rid; + $record->questionid = $question->id; + + file_save_draft_area_files($val, $question->context->id, + 'mod_questionnaire', 'file', $val, + \mod_questionnaire\question\file::get_file_manager_option()); + $fs = get_file_storage(); + $files = $fs->get_area_files($question->context->id, 'mod_questionnaire', + 'file', $val, + "itemid, filepath, filename", + false); + if (!empty($files)) { + $file = reset($files); + $record->value = $file->get_id(); + $answers[] = answer\answer::create_from_data($record); + } else { + self::delete_old_response((int)$question->id, (int)$record->responseid); + } + } + return $answers; + } + + /** + * Return an array of answers by question/choice for the given response. Must be implemented by the subclass. + * + * @param int $rid The response id. + * @return array + */ + public static function response_select($rid) { + global $DB; + + $values = []; + $sql = 'SELECT q.id, q.content, a.fileid as aresponse ' . + 'FROM {' . static::response_table() . '} a, {questionnaire_question} q ' . + 'WHERE a.response_id=? AND a.question_id=q.id '; + $records = $DB->get_records_sql($sql, [$rid]); + foreach ($records as $qid => $row) { + unset($row->id); + $row = (array) $row; + $newrow = []; + foreach ($row as $key => $val) { + if (!is_numeric($key)) { + $newrow[] = $val; + } + } + $values[$qid] = $newrow; + $val = array_pop($values[$qid]); + array_push($values[$qid], $val, $val); + } + + return $values; + } + + /** + * Return an array of answer objects by question for the given response id. + * THIS SHOULD REPLACE response_select. + * + * @param int $rid The response id. + * @return array array answer + * @throws \dml_exception + */ + public static function response_answers_by_question($rid) { + global $DB; + + $answers = []; + $sql = 'SELECT id, response_id as responseid, question_id as questionid, 0 as choiceid, fileid as value ' . + 'FROM {' . static::response_table() . '} ' . + 'WHERE response_id = ? '; + $records = $DB->get_records_sql($sql, [$rid]); + foreach ($records as $record) { + $answers[$record->questionid][] = answer\answer::create_from_data($record); + } + + return $answers; + } + + /** + * Delete old entries from the questionnaire_response_file table and also the corresponding entries + * in the files table. + * @param int $questionid + * @param int $responseid + * @return void + * @throws \dml_exception + */ + public static function delete_old_response(int $questionid, int $responseid) { + global $DB; + // Check, if we have an old response file from a former attempt. + $record = $DB->get_record(static::response_table(), [ + 'response_id' => $responseid, + 'question_id' => $questionid, + ]); + if ($record) { + // Old record found, then delete all referenced entries in the files table and then delete this entry. + $DB->delete_records('files', ['component' => 'mod_questionnaire', 'itemid' => $record->itemid]); + $DB->delete_records(self::response_table(), ['id' => $record->id]); + } + } + + /** + * Insert a provided response to the question. + * + * @param \mod_questionnaire\responsetype\response\response|\stdClass $responsedata + * @return bool|int + * @throws \dml_exception + */ + public function insert_response($responsedata) { + global $DB; + + if (!$responsedata instanceof \mod_questionnaire\responsetype\response\response) { + $response = \mod_questionnaire\responsetype\response\response::response_from_webform($responsedata, [$this->question]); + } else { + $response = $responsedata; + } + + if (!empty($response) && isset($response->answers[$this->question->id][0])) { + $record = new \stdClass(); + $record->response_id = $response->id; + $record->question_id = $this->question->id; + $record->fileid = intval(clean_text($response->answers[$this->question->id][0]->value)); + + // Delete any previous attempts. + self::delete_old_response((int)$this->question->id, (int)$response->id); + + // When saving the draft file, the itemid was the same as the draftitemid. This must now be + // corrected to the primary key that is questionaire_response_file.id to have a correct reference. + $recordid = $DB->insert_record(static::response_table(), $record); + if ($recordid) { + $olditem = $DB->get_record('files', ['id' => $record->fileid], 'itemid'); + if (!$olditem) { + return false; + } + $siblings = $DB->get_records('files', + ['component' => 'mod_questionnaire', 'itemid' => $olditem->itemid]); + foreach ($siblings as $sibling) { + if (!self::fix_file_itemid($recordid, $sibling)) { + return false; + } + } + return $recordid; + } + } + return false; + } + + /** + * Update records in the table file with the new given itemid. To do this, the pathnamehash + * needs to be recalculated as well. + * @param int $recordid + * @param \stdClass $filerecord + * @return bool + * @throws \dml_exception + */ + public static function fix_file_itemid(int $recordid, \stdClass $filerecord): bool { + global $DB; + if ((int)$filerecord->itemid === $recordid) { + return true; // Reference is already good, nothing to do. + } + $fs = get_file_storage(); + $file = $fs->get_file_instance($filerecord); + $newhash = $fs->get_pathname_hash($filerecord->contextid, $filerecord->component, + $filerecord->filearea, $recordid, $file->get_filepath(), $file->get_filename()); + $filerecord->itemid = $recordid; + $filerecord->pathnamehash = $newhash; + return $DB->update_record('files', $filerecord); + } + + /** + * Provide the necessary response data table name. Should probably always be used with late static binding 'static::' form + * rather than 'self::' form to allow for class extending. + * + * @return string response table name. + */ + public static function response_table() { + return 'questionnaire_response_file'; + } + + /** + * Provide a template for results screen if defined. + * + * @param bool $pdf + * @return mixed The template string or false/ + */ + public function results_template($pdf = false) { + if ($pdf) { + return 'mod_questionnaire/resultspdf_text'; + } else { + return 'mod_questionnaire/results_text'; + } + } + + /** + * Provide the result information for the specified result records. + * + * @param int|array $rids - A single response id, or array. + * @param string $sort - Optional display sort. + * @param boolean $anonymous - Whether or not responses are anonymous. + * @return string - Display output. + */ + public function display_results($rids = false, $sort = '', $anonymous = false) { + if (is_array($rids)) { + $prtotal = 1; + } else if (is_int($rids)) { + $prtotal = 0; + } + if ($rows = $this->get_results($rids, $anonymous)) { + $numrespondents = count($rids); + $numresponses = count($rows); + $pagetags = $this->get_results_tags($rows, $numrespondents, $numresponses, $prtotal); + } else { + $pagetags = ""; + } + return $pagetags; + } + + /** + * Provide the result information for the specified result records. + * + * @param int|array $rids - A single response id, or array. + * @param boolean $anonymous - Whether or not responses are anonymous. + * @return array - Array of data records. + */ + public function get_results($rids = false, $anonymous = false) { + global $DB; + + $rsql = ''; + if (!empty($rids)) { + list($rsql, $params) = $DB->get_in_or_equal($rids); + $rsql = ' AND response_id ' . $rsql; + } + + if ($anonymous) { + $sql = 'SELECT t.id, t.fileid, r.submitted AS submitted, ' . + 'r.questionnaireid, r.id AS rid ' . + 'FROM {' . static::response_table() . '} t, ' . + '{questionnaire_response} r ' . + 'WHERE question_id=' . $this->question->id . $rsql . + ' AND t.response_id = r.id ' . + 'ORDER BY r.submitted DESC'; + } else { + $sql = 'SELECT t.id, t.fileid, r.submitted AS submitted, r.userid, u.username AS username, ' . + 'u.id as usrid, ' . + 'r.questionnaireid, r.id AS rid ' . + 'FROM {' . static::response_table() . '} t, ' . + '{questionnaire_response} r, ' . + '{user} u ' . + 'WHERE question_id=' . $this->question->id . $rsql . + ' AND t.response_id = r.id' . + ' AND u.id = r.userid ' . + 'ORDER BY u.lastname, u.firstname, r.submitted'; + } + return $DB->get_records_sql($sql, $params); + } + + /** + * Override the results tags function for templates for questions with dates. + * + * @param array $weights + * @param int $participants Number of questionnaire participants. + * @param int $respondents Number of question respondents. + * @param bool $showtotals + * @param string $sort + * @return \stdClass + */ + public function get_results_tags($weights, $participants, $respondents, $showtotals = 1, $sort = '') { + $pagetags = new \stdClass(); + if ($respondents == 0) { + return $pagetags; + } + + // If array element is an object, outputting non-numeric responses. + if (is_object(reset($weights))) { + global $CFG, $SESSION, $questionnaire, $DB; + $viewsingleresponse = $questionnaire->capabilities->viewsingleresponse; + $nonanonymous = $questionnaire->respondenttype != 'anonymous'; + if ($viewsingleresponse && $nonanonymous) { + $currentgroupid = ''; + if (isset($SESSION->questionnaire->currentgroupid)) { + $currentgroupid = $SESSION->questionnaire->currentgroupid; + } + $url = $CFG->wwwroot . '/mod/questionnaire/report.php?action=vresp&sid=' . $questionnaire->survey->id . + '¤tgroupid=' . $currentgroupid; + } + $users = []; + $evencolor = false; + foreach ($weights as $row) { + $response = new \stdClass(); + $fs = get_file_storage(); + $file = $fs->get_file_by_id($row->fileid); + + if ($file) { + // There is a file. + $imageurl = moodle_url::make_pluginfile_url( + $file->get_contextid(), + $file->get_component(), + $file->get_filearea(), + $file->get_itemid(), + $file->get_filepath(), + $file->get_filename()); + + $response->text = \html_writer::link($imageurl, $file->get_filename()); + if ($viewsingleresponse && $nonanonymous) { + $rurl = $url . '&rid=' . $row->rid . '&individualresponse=1'; + $title = userdate($row->submitted); + if (!isset($users[$row->userid])) { + $users[$row->userid] = $DB->get_record('user', ['id' => $row->userid]); + } + $response->respondent = + '' . fullname($users[$row->userid]) . ''; + } + } else { + $response->respondent = ''; + } + // The 'evencolor' attribute is used by the PDF template. + $response->evencolor = $evencolor; + $pagetags->responses[] = (object) ['response' => $response]; + $evencolor = !$evencolor; + } + + if ($showtotals == 1) { + $pagetags->total = new \stdClass(); + $pagetags->total->total = "$respondents/$participants"; + } + } else { + $nbresponses = 0; + $sum = 0; + $strtotal = get_string('totalofnumbers', 'questionnaire'); + $straverage = get_string('average', 'questionnaire'); + + if (!empty($weights) && is_array($weights)) { + ksort($weights); + $evencolor = false; + foreach ($weights as $text => $num) { + $response = new \stdClass(); + $response->text = $text; + $response->respondent = $num; + // The 'evencolor' attribute is used by the PDF template. + $response->evencolor = $evencolor; + $nbresponses += $num; + $sum += $text * $num; + $evencolor = !$evencolor; + $pagetags->responses[] = (object) ['response' => $response]; + } + + $response = new \stdClass(); + $response->text = $sum; + $response->respondent = $strtotal; + $response->evencolor = $evencolor; + $pagetags->responses[] = (object) ['response' => $response]; + $evencolor = !$evencolor; + + $response = new \stdClass(); + $response->respondent = $straverage; + $avg = $sum / $nbresponses; + $response->text = sprintf('%.' . $this->question->precise . 'f', $avg); + $response->evencolor = $evencolor; + $pagetags->responses[] = (object) ['response' => $response]; + $evencolor = !$evencolor; + + if ($showtotals == 1) { + $pagetags->total = new \stdClass(); + $pagetags->total->total = "$respondents/$participants"; + $pagetags->total->evencolor = $evencolor; + } + } + } + + return $pagetags; + } + + /** + * Configure bulk sql + * + * @return bulk_sql_config + */ + protected function bulk_sql_config() { + return new bulk_sql_config(static::response_table(), 'qrt', false, false, false); + } +} + diff --git a/classes/responsetype/response/response.php b/classes/responsetype/response/response.php index 070160d2..05e63e12 100644 --- a/classes/responsetype/response/response.php +++ b/classes/responsetype/response/response.php @@ -99,9 +99,9 @@ public static function create_from_data($responsedata) { /** * Provide a response object from web form data to the question. * - * @param \stdClass $responsedata All of the responsedata as an object. + * @param \stdClass $responsedata All the responsedata as an object. * @param array $questions - * @return bool|response A response object. + * @return response A response object. */ public static function response_from_webform($responsedata, $questions) { global $USER; @@ -169,5 +169,6 @@ public function add_questions_answers() { $this->answers += \mod_questionnaire\responsetype\boolean::response_answers_by_question($this->id); $this->answers += \mod_questionnaire\responsetype\date::response_answers_by_question($this->id); $this->answers += \mod_questionnaire\responsetype\text::response_answers_by_question($this->id); + $this->answers += \mod_questionnaire\responsetype\file::response_answers_by_question($this->id); } } diff --git a/db/install.php b/db/install.php index 55b0eda4..33153962 100644 --- a/db/install.php +++ b/db/install.php @@ -114,4 +114,10 @@ function xmldb_questionnaire_install() { $questiontype->response_table = ''; $id = $DB->insert_record('questionnaire_question_type', $questiontype); + $questiontype = new stdClass(); + $questiontype->typeid = 12; + $questiontype->type = 'File'; + $questiontype->has_choices = 'n'; + $questiontype->response_table = 'response_file'; + $id = $DB->insert_record('questionnaire_question_type', $questiontype); } diff --git a/db/install.xml b/db/install.xml index a1b7af0b..4fdc7ff7 100644 --- a/db/install.xml +++ b/db/install.xml @@ -225,6 +225,21 @@ + + + + + + + + + + + + + + +
diff --git a/db/upgrade.php b/db/upgrade.php index dd5fd989..11336692 100644 --- a/db/upgrade.php +++ b/db/upgrade.php @@ -1002,6 +1002,54 @@ function xmldb_questionnaire_upgrade($oldversion=0) { upgrade_mod_savepoint(true, 2022121600.02, 'questionnaire'); } + if ($oldversion < 2023101500) { + $questiontype = new stdClass(); + $questiontype->typeid = 12; + $questiontype->type = 'File'; + $questiontype->has_choices = 'n'; + $questiontype->response_table = 'response_file'; + $id = $DB->insert_record('questionnaire_question_type', $questiontype); + + // Define table questionnaire_response_file to be created. + $table = new xmldb_table('questionnaire_response_file'); + + // Adding fields to table questionnaire_response_file. + $table->add_field('id', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, XMLDB_SEQUENCE, null); + $table->add_field('response_id', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, '0'); + $table->add_field('question_id', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, '0'); + $table->add_field('fileid', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, null); + + // Adding keys to table questionnaire_response_file. + $table->add_key('primary', XMLDB_KEY_PRIMARY, ['id']); + $table->add_key('file_fk', XMLDB_KEY_FOREIGN, ['fileid'], 'files', ['id']); + + // Adding indexes to table questionnaire_response_file. + $table->add_index('response_question', XMLDB_INDEX_NOTUNIQUE, ['response_id', 'question_id']); + + // Conditionally launch create table for questionnaire_response_file. + if (!$dbman->table_exists($table)) { + $dbman->create_table($table); + } + + // Questionnaire savepoint reached. + upgrade_mod_savepoint(true, 2023101500, 'questionnaire'); + } + + if ($oldversion < 2023101501) { + // Upgrade files.itemid with questionnaire_response_file.id. + $filesresponses = $DB->get_records('questionnaire_response_file', [], '', 'id,fileid'); + $idmap = []; + foreach ($filesresponses as $fileresponse) { + $idmap[(int)$fileresponse->fileid] = (int)$fileresponse->id; + } + $filerecords = $DB->get_records_list('files', 'id', array_keys($idmap), 'id desc'); + foreach ($filerecords as $filerecord) { + \mod_questionnaire\responsetype\file::fix_file_itemid($idmap[(int)$filerecord->id], $filerecord); + } + // Questionnaire savepoint reached. + upgrade_mod_savepoint(true, 2023101501, 'questionnaire'); + } + return true; } diff --git a/lang/en/questionnaire.php b/lang/en/questionnaire.php index 7de8d9f7..d62f3630 100644 --- a/lang/en/questionnaire.php +++ b/lang/en/questionnaire.php @@ -246,6 +246,8 @@ respondent. Default values are 20 characters for the Input Box width and 25 characters for the maximum length of text entered.'; +$string['file'] = 'File'; +$string['file_help'] = 'Allow user to submit a file (only simple, virus safe formats are allowed)'; $string['finished'] = 'You have answered all the questions in this questionnaire!'; $string['firstrespondent'] = 'First Respondent'; $string['formateditor'] = 'HTML editor'; diff --git a/lib.php b/lib.php index dd7f2ea0..d462c3a6 100644 --- a/lib.php +++ b/lib.php @@ -521,11 +521,12 @@ function questionnaire_scale_used_anywhere($scaleid) { * @param string $filearea * @param array $args * @param bool $forcedownload + * @param mixed $options * @return bool false if file not found, does not return if found - justsend the file * * $forcedownload is unused, but API requires it. Suppress PHPMD warning. */ -function questionnaire_pluginfile($course, $cm, $context, $filearea, $args, $forcedownload) { +function questionnaire_pluginfile($course, $cm, $context, $filearea, $args, $forcedownload, $options) { global $DB; if ($context->contextlevel != CONTEXT_MODULE) { @@ -534,7 +535,7 @@ function questionnaire_pluginfile($course, $cm, $context, $filearea, $args, $for require_course_login($course, true, $cm); - $fileareas = ['intro', 'info', 'thankbody', 'question', 'feedbacknotes', 'sectionheading', 'feedback']; + $fileareas = ['intro', 'info', 'thankbody', 'question', 'feedbacknotes', 'sectionheading', 'feedback', 'file']; if (!in_array($filearea, $fileareas)) { return false; } @@ -553,6 +554,10 @@ function questionnaire_pluginfile($course, $cm, $context, $filearea, $args, $for if (!$DB->record_exists('questionnaire_feedback', ['id' => $componentid])) { return false; } + } else if ($filearea == 'file') { + if (!$DB->record_exists('questionnaire_response_file', ['id' => $componentid])) { + return false; + } } else { if (!$DB->record_exists('questionnaire_survey', ['id' => $componentid])) { return false; @@ -571,7 +576,7 @@ function questionnaire_pluginfile($course, $cm, $context, $filearea, $args, $for } // Finally send the file. - send_stored_file($file, 0, 0, true); // Download MUST be forced - security! + send_stored_file($file, null, 0, $forcedownload, $options); // Download MUST be forced - security! } /** * Adds module specific settings to the settings block diff --git a/locallib.php b/locallib.php index 4fce8590..8e9185b9 100644 --- a/locallib.php +++ b/locallib.php @@ -65,10 +65,10 @@ global $questionnaireresponseviewers; $questionnaireresponseviewers = array ( + QUESTIONNAIRE_STUDENTVIEWRESPONSES_NEVER => get_string('responseviewstudentsnever', 'questionnaire'), QUESTIONNAIRE_STUDENTVIEWRESPONSES_WHENANSWERED => get_string('responseviewstudentswhenanswered', 'questionnaire'), QUESTIONNAIRE_STUDENTVIEWRESPONSES_WHENCLOSED => get_string('responseviewstudentswhenclosed', 'questionnaire'), - QUESTIONNAIRE_STUDENTVIEWRESPONSES_ALWAYS => get_string('responseviewstudentsalways', 'questionnaire'), - QUESTIONNAIRE_STUDENTVIEWRESPONSES_NEVER => get_string('responseviewstudentsnever', 'questionnaire')); + QUESTIONNAIRE_STUDENTVIEWRESPONSES_ALWAYS => get_string('responseviewstudentsalways', 'questionnaire')); global $autonumbering; $autonumbering = array (0 => get_string('autonumberno', 'questionnaire'), @@ -317,13 +317,14 @@ function questionnaire_delete_response($response, $questionnaire='') { } // Delete all of the response data for a response. - $DB->delete_records('questionnaire_response_bool', array('response_id' => $rid)); - $DB->delete_records('questionnaire_response_date', array('response_id' => $rid)); - $DB->delete_records('questionnaire_resp_multiple', array('response_id' => $rid)); - $DB->delete_records('questionnaire_response_other', array('response_id' => $rid)); - $DB->delete_records('questionnaire_response_rank', array('response_id' => $rid)); - $DB->delete_records('questionnaire_resp_single', array('response_id' => $rid)); - $DB->delete_records('questionnaire_response_text', array('response_id' => $rid)); + $DB->delete_records('questionnaire_response_bool', ['response_id' => $rid]); + $DB->delete_records('questionnaire_response_date', ['response_id' => $rid]); + $DB->delete_records('questionnaire_resp_multiple', ['response_id' => $rid]); + $DB->delete_records('questionnaire_response_other', ['response_id' => $rid]); + $DB->delete_records('questionnaire_response_rank', ['response_id' => $rid]); + $DB->delete_records('questionnaire_resp_single', ['response_id' => $rid]); + $DB->delete_records('questionnaire_response_text', ['response_id' => $rid]); + $DB->delete_records('questionnaire_response_file', ['response_id' => $rid]); $status = $status && $DB->delete_records('questionnaire_response', array('id' => $rid)); @@ -354,6 +355,7 @@ function questionnaire_delete_responses($qid) { $DB->delete_records('questionnaire_response_rank', ['question_id' => $qid]); $DB->delete_records('questionnaire_resp_single', ['question_id' => $qid]); $DB->delete_records('questionnaire_response_text', ['question_id' => $qid]); + $DB->delete_records('questionnaire_response_file', ['question_id' => $qid]); return true; } @@ -498,6 +500,8 @@ function questionnaire_get_type ($id) { return get_string('numeric', 'questionnaire'); case 11: return get_string('slider', 'questionnaire'); + case 12: + return get_string('file', 'questionnaire'); case 100: return get_string('sectiontext', 'questionnaire'); case 99: diff --git a/questionnaire.class.php b/questionnaire.class.php index e366f70b..e654417b 100644 --- a/questionnaire.class.php +++ b/questionnaire.class.php @@ -122,12 +122,12 @@ public function add_survey($sid = 0, $survey = null) { /** * Adding questions to the object. - * @param bool $sid + * @param int $sid */ - public function add_questions($sid = false) { + public function add_questions($sid = 0) { global $DB; - if ($sid === false) { + if ($sid === 0) { $sid = $this->sid; } @@ -826,9 +826,10 @@ public function count_submissions($userid=false, $groupid=0) { * * @param int|bool $userid * @param int $groupid + * @param int|bool $includeincomplete * @return array */ - public function get_responses($userid=false, $groupid=0) { + public function get_responses($userid=false, $groupid=0, $includeincomplete=false) { global $DB; $params = []; @@ -840,6 +841,12 @@ public function get_responses($userid=false, $groupid=0) { $params['groupid'] = $groupid; } + $statuscnd = ''; + if (!$includeincomplete) { + $statuscnd = ' AND r.complete = :status '; + $params['status'] = 'y'; + } + // Since submission can be across questionnaires in the case of public questionnaires, need to check the realm. // Public questionnaires can have responses to multiple questionnaire instances. if ($this->survey_is_public_master()) { @@ -848,16 +855,14 @@ public function get_responses($userid=false, $groupid=0) { 'INNER JOIN {questionnaire} q ON r.questionnaireid = q.id ' . 'INNER JOIN {questionnaire_survey} s ON q.sid = s.id ' . $groupsql . - 'WHERE s.id = :surveyid AND r.complete = :status' . $groupcnd; + 'WHERE s.id = :surveyid' . $statuscnd . $groupcnd; $params['surveyid'] = $this->sid; - $params['status'] = 'y'; } else { $sql = 'SELECT r.* ' . 'FROM {questionnaire_response} r ' . $groupsql . - 'WHERE r.questionnaireid = :questionnaireid AND r.complete = :status' . $groupcnd; + 'WHERE r.questionnaireid = :questionnaireid' . $statuscnd . $groupcnd; $params['questionnaireid'] = $this->id; - $params['status'] = 'y'; } if ($userid) { $sql .= ' AND r.userid = :userid'; diff --git a/report.php b/report.php index b7b16152..9b42f6fe 100755 --- a/report.php +++ b/report.php @@ -267,6 +267,9 @@ case 'delallresp': // Delete all responses? Ask for confirmation. require_capability('mod/questionnaire:deleteresponses', $context); + // Get all responses including incompletes. + $respsallparticipants = $questionnaire->get_responses(false, 0, true); + if (!empty($respsallparticipants)) { // Print the page header. @@ -357,6 +360,9 @@ throw new \moodle_exception('surveyowner', 'mod_questionnaire'); } + // Get all responses including incompletes. + $respsallparticipants = $questionnaire->get_responses(false, 0, true); + // Available group modes (0 = no groups; 1 = separate groups; 2 = visible groups). if ($groupmode > 0) { switch ($currentgroupid) { @@ -485,7 +491,6 @@ case 'dfs': require_capability('mod/questionnaire:downloadresponses', $context); - require_once($CFG->dirroot . '/lib/dataformatlib.php'); // Use the questionnaire name as the file name. Clean it and change any non-filename characters to '_'. $name = clean_param($questionnaire->name, PARAM_FILE); $name = preg_replace("/[^A-Z0-9]+/i", "_", trim($name)); diff --git a/tests/behat/add_questions.feature b/tests/behat/add_questions.feature index 54b4fdf0..4f020693 100644 --- a/tests/behat/add_questions.feature +++ b/tests/behat/add_questions.feature @@ -99,4 +99,8 @@ Feature: Add questions to a questionnaire activity And I should see "Choose yes or no" And I set the field "id_type_id" to "----- Page Break -----" And I press "Add selected question type" + And I add a "File" question and I fill the form with: + | Question Name | Q10 | + | Yes | Yes | + | Question Text | Add a file as an answer | Then I should see "[----- Page Break -----]" diff --git a/tests/behat/behat_mod_questionnaire.php b/tests/behat/behat_mod_questionnaire.php index 57c97c58..94e2ba53 100644 --- a/tests/behat/behat_mod_questionnaire.php +++ b/tests/behat/behat_mod_questionnaire.php @@ -30,7 +30,6 @@ use Behat\Behat\Context\Step\Given as Given, Behat\Behat\Context\Step\When as When, Behat\Gherkin\Node\TableNode as TableNode, - Behat\Gherkin\Node\PyStringNode as PyStringNode, Behat\Mink\Exception\ExpectationException as ExpectationException; #[\AllowDynamicProperties] @@ -120,7 +119,8 @@ public function i_add_a_question_and_i_fill_the_form_with($questiontype, TableNo 'Rate (scale 1..5)', 'Text Box', 'Yes/No', - 'Slider'); + 'Slider', + 'File'); if (!in_array($questiontype, $validtypes)) { throw new ExpectationException('Invalid question type specified.', $this->getSession()); @@ -465,4 +465,189 @@ protected function get_cm_by_questionnaire_name(string $name): stdClass { $questionnaire = $this->get_questionnaire_by_name($name); return get_coursemodule_from_instance('questionnaire', $questionnaire->id, $questionnaire->course); } + + /** + * Uploads a file to the specified filemanager leaving other fields in upload form default. + * + * The paths should be relative to moodle codebase. + * + * @When /^I upload "(?P(?:[^"]|\\")*)" to questionnaire "(?P(?:[^"]|\\")*)" filemanager$/ + * @param string $filepath + * @param string $question + */ + public function i_upload_file_to_questionnaire_question_filemanager($filepath, $question) { + $this->upload_file_to_question_filemanager_questionnaire($filepath, $question, new TableNode([]), false); + } + + /** + * Try to get the filemanager node of a given question. + * + * @param $question + * @return \Behat\Mink\Element\NodeElement|null + */ + protected function get_filepicker_node($question) { + // More info about the problem (in case there is a problem). + $exception = new ExpectationException('The filepicker for the question with text "' . $question . + '" can not be found', $this->getSession()); + + $filepickercontainer = $this->find( + 'xpath', + "//p[contains(.,'" . $question . "')]" . + "//parent::div[contains(concat(' ', normalize-space(@class), ' '), ' no-overflow ')]" . + "//parent::div[contains(concat(' ', normalize-space(@class), ' '), ' qn-question ')]" . + "//following::div[contains(concat(' ', normalize-space(@class), ' '), ' qn-answer ')]" . + "//descendant::*[@data-fieldtype = 'filemanager' or @data-fieldtype = 'filepicker']", + $exception + ); + + return $filepickercontainer; + } + + /** + * Uploads a file to filemanager + * + * @param string $filepath Normally a path relative to $CFG->dirroot, but can be an absolute path too. + * @param string $question A question text. + * @param TableNode $data Data to fill in upload form + * @param false|string $overwriteaction false if we don't expect that file with the same name already exists, + * or button text in overwrite dialogue ("Overwrite", "Rename to ...", "Cancel") + * @throws DriverException + * @throws ExpectationException Thrown by behat_base::find + */ + protected function upload_file_to_question_filemanager_questionnaire($filepath, $question, TableNode $data, + $overwriteaction = false) { + global $CFG; + + if (!$this->has_tag('_file_upload')) { + throw new DriverException('File upload tests must have the @_file_upload tag on either the scenario or feature.'); + } + + $filemanagernode = $this->get_filepicker_node($question); + + // Opening the select repository window and selecting the upload repository. + $this->open_add_file_window($filemanagernode, get_string('pluginname', 'repository_upload')); + + // Ensure all the form is ready. + $noformexception = new ExpectationException('The upload file form is not ready', $this->getSession()); + $this->find( + 'xpath', + "//div[contains(concat(' ', normalize-space(@class), ' '), ' container ')]" . + "[contains(concat(' ', normalize-space(@class), ' '), ' repository_upload ')]" . + "/descendant::div[contains(concat(' ', normalize-space(@class), ' '), ' file-picker ')]" . + "/descendant::div[contains(concat(' ', normalize-space(@class), ' '), ' fp-content ')]" . + "/descendant::div[contains(concat(' ', normalize-space(@class), ' '), ' fp-upload-form ')]" . + "/descendant::form", + $noformexception + ); + // After this we have the elements we want to interact with. + + // Form elements to interact with. + $file = $this->find_file('repo_upload_file'); + + // Attaching specified file to the node. + // Replace 'admin/' if it is in start of path with $CFG->admin . + if (substr($filepath, 0, 6) === 'admin/') { + $filepath = $CFG->dirroot . DIRECTORY_SEPARATOR . $CFG->admin . + DIRECTORY_SEPARATOR . substr($filepath, 6); + } + $filepath = str_replace('/', DIRECTORY_SEPARATOR, $filepath); + if (!is_readable($filepath)) { + $filepath = $CFG->dirroot . DIRECTORY_SEPARATOR . $filepath; + if (!is_readable($filepath)) { + throw new ExpectationException('The file to be uploaded does not exist.', $this->getSession()); + } + } + $file->attachFile($filepath); + + // Fill the form in Upload window. + $datahash = $data->getRowsHash(); + + // The action depends on the field type. + foreach ($datahash as $locator => $value) { + + $field = behat_field_manager::get_form_field_from_label($locator, $this); + + // Delegates to the field class. + $field->set_value($value); + } + + // Submit the file. + $submit = $this->find_button(get_string('upload', 'repository')); + $submit->press(); + + // We wait for all the JS to finish as it is performing an action. + $this->getSession()->wait(self::get_timeout(), self::PAGE_READY_JS); + + if ($overwriteaction !== false) { + $overwritebutton = $this->find_button($overwriteaction); + $this->ensure_node_is_visible($overwritebutton); + $overwritebutton->click(); + + // We wait for all the JS to finish. + $this->getSession()->wait(self::get_timeout(), self::PAGE_READY_JS); + } + + } + + /** + * Opens the filepicker modal window and selects the repository. + * + * @param NodeElement $filemanagernode The filemanager or filepicker form element DOM node. + * @param mixed $repositoryname The repo name. + * @return void + * @throws ExpectationException Thrown by behat_base::find + */ + protected function open_add_file_window($filemanagernode, $repositoryname) { + $exception = new ExpectationException('No files can be added to the specified filemanager', $this->getSession()); + + // We should deal with single-file and multiple-file filemanagers, + // catching the exception thrown by behat_base::find() in case is not multiple. + $this->execute('behat_general::i_click_on_in_the', [ + 'div.fp-btn-add a, input.fp-btn-choose', 'css_element', + $filemanagernode, 'NodeElement' + ]); + + // Wait for the default repository (if any) to load. This checks that + // the relevant div exists and that it does not include the loading image. + $this->ensure_element_exists( + "//div[contains(concat(' ', normalize-space(@class), ' '), ' file-picker ')]" . + "//div[contains(concat(' ', normalize-space(@class), ' '), ' fp-content ')]" . + "[not(descendant::div[contains(concat(' ', normalize-space(@class), ' '), ' fp-content-loading ')])]", + 'xpath_element'); + + // Getting the repository link and opening it. + $repoexception = + new ExpectationException('The "' . $repositoryname . '" repository has not been found', $this->getSession()); + + // Avoid problems with both double and single quotes in the same string. + $repositoryname = behat_context_helper::escape($repositoryname); + + // Here we don't need to look inside the selected element because there can only be one modal window. + // Apparently there are some of these repo elements. So if the first one is not visible, check out + // the next one. + $repositorylinks = $this->find_all( + 'xpath', + "//div[contains(concat(' ', normalize-space(@class), ' '), ' fp-repo-area ')]" . + "//descendant::span[contains(concat(' ', normalize-space(@class), ' '), ' fp-repo-name ')]" . + "[normalize-space(.)=$repositoryname]", + $repoexception + ); + + foreach ($repositorylinks as $repositorylink) { + try { + $this->ensure_node_is_visible($repositorylink); + } catch (Exception $exception) { + $repositorylink = $exception; + } + } + if ($repositorylink instanceof \Exception) { + throw new $repositorylink; + } + // Selecting the repo. + if (!$repositorylink->getParent()->getParent()->hasClass('active')) { + // If the repository link is active, then the repository is already loaded. + // Clicking it while it's active causes issues, so only click it when it isn't (see MDL-51014). + $this->execute('behat_general::i_click_on', [$repositorylink, 'NodeElement']); + } + } } diff --git a/tests/behat/file_question.feature b/tests/behat/file_question.feature new file mode 100644 index 00000000..855d8350 --- /dev/null +++ b/tests/behat/file_question.feature @@ -0,0 +1,80 @@ +@mod @mod_questionnaire +Feature: Add a question requiring a file upload in questionnaire. + In order to use this plugin + As a teacher + I need to add a a file question to a questionnaire created in my course + and a student answers to it. Then the file has to be accessible. + + Background: + Given the following "users" exist: + | username | firstname | lastname | email | + | teacher1 | Teacher | 1 | teacher1@example.com | + | student1 | Student | 1 | student1@example.com | + And the following "courses" exist: + | fullname | shortname | category | + | Course 1 | C1 | 0 | + And the following "course enrolments" exist: + | user | course | role | + | teacher1 | C1 | editingteacher | + | student1 | C1 | student | + And the following "activities" exist: + | activity | name | description | course | idnumber | resume | navigate | + | questionnaire | Test questionnaire | Test questionnaire description | C1 | questionnaire0 | 1 | 1 | + + @javascript @_file_upload + Scenario: Add a single file question to a questionnaire and view an answer with an uploaded file. + Given I log in as "teacher1" + When I am on the "Test questionnaire" "questionnaire activity" page + And I navigate to "Questions" in current page administration + And I should see "Add questions" + And I add a "File" question and I fill the form with: + | Question Name | File question | + | Yes | Yes | + | Question Text | Add a file as an answer | + And I log out + And I log in as "student1" + And I am on the "Test questionnaire" "questionnaire activity" page + And I navigate to "Answer the questions..." in current page administration + And I upload "mod/questionnaire/tests/fixtures/testfilequestion.pdf" to questionnaire "Add a file as an answer" filemanager + And I press "Submit questionnaire" + And I should see "Thank you for completing this Questionnaire" + And I press "Continue" + And I should see "View your response(s)" + And ".resourcecontent.resourcepdf" "css_element" should exist + And I log out + And I log in as "teacher1" + And I am on the "Test questionnaire" "questionnaire activity" page + And I navigate to "View all responses" in current page administration + Then I should see "testfilequestion.pdf" + + @javascript @_file_upload + Scenario: Add two file questions to a questionnaire and view an answer with two uploaded file. + Given I log in as "teacher1" + When I am on the "Test questionnaire" "questionnaire activity" page + And I navigate to "Questions" in current page administration + And I should see "Add questions" + And I add a "File" question and I fill the form with: + | Question Name | File question one | + | Yes | Yes | + | Question Text | Add a first file as an answer | + And I add a "File" question and I fill the form with: + | Question Name | File question two | + | Yes | Yes | + | Question Text | Add a second file as an answer | + And I log out + And I log in as "student1" + And I am on the "Test questionnaire" "questionnaire activity" page + And I navigate to "Answer the questions..." in current page administration + And I upload "mod/questionnaire/tests/fixtures/testfilequestion.pdf" to questionnaire "Add a first file as an answer" filemanager + And I upload "mod/questionnaire/tests/fixtures/testfilequestion2.pdf" to questionnaire "Add a second file as an answer" filemanager + And I press "Submit questionnaire" + And I should see "Thank you for completing this Questionnaire" + And I press "Continue" + And I should see "View your response(s)" + And ".resourcecontent.resourcepdf" "css_element" should exist + And I log out + And I log in as "teacher1" + And I am on the "Test questionnaire" "questionnaire activity" page + And I navigate to "View all responses" in current page administration + Then I should see "testfilequestion.pdf" + And I should see "testfilequestion2.pdf" diff --git a/tests/behat/questionnaire_activity_completion.feature b/tests/behat/questionnaire_activity_completion.feature index a9082e39..32e9e856 100644 --- a/tests/behat/questionnaire_activity_completion.feature +++ b/tests/behat/questionnaire_activity_completion.feature @@ -108,8 +108,8 @@ Feature: View activity completion information in the questionnaire activity Then I should see "Are you still in School?" And I should see "Select one choice" And I should see "Enter some text" - And I set the field "Yes" to "1" - And I set the field "Three" to "1" + And I set the field with xpath "//input[@type='radio' and @id='auto-rb0001']" to "1" + And I set the field with xpath "//input[@type='radio' and @id='auto-rb0005']" to "1" And I press "Submit questionnaire" Then I should see "Thank you for completing this Questionnaire." And I press "Continue" diff --git a/tests/csvexport_test.php b/tests/csvexport_test.php index bf90dbef..4752aefc 100644 --- a/tests/csvexport_test.php +++ b/tests/csvexport_test.php @@ -14,15 +14,6 @@ // You should have received a copy of the GNU General Public License // along with Moodle. If not, see . -/** - * Performance test for questionnaire module. - * @package mod_questionnaire - * @group mod_questionnaire - * @author Guy Thomas - * @copyright Copyright (c) 2015 Moodlerooms Inc. (http://www.moodlerooms.com) - * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later - */ - namespace mod_questionnaire; /** @@ -59,9 +50,9 @@ private function get_csv_text(array $rows) { } /** - * Tests the CSV export. + * Test case for the csvexport method. * - * @covers \questionnaire::generate_csv + * @covers ::csvexport */ public function test_csvexport() { $this->resetAfterTest(); diff --git a/tests/custom_completion_test.php b/tests/custom_completion_test.php index e46ce656..8a9da2ae 100644 --- a/tests/custom_completion_test.php +++ b/tests/custom_completion_test.php @@ -117,7 +117,7 @@ public function test_get_state(string $rule, int $available, ?bool $submitted, ? /** * Test for get_defined_custom_rules(). * - * @covers \mod_questionnaire\completion\custom_completion + * @covers \mod_questionnaire\completion\custom_completion::get_defined_custom_rules */ public function test_get_defined_custom_rules() { $rules = custom_completion::get_defined_custom_rules(); @@ -128,7 +128,7 @@ public function test_get_defined_custom_rules() { /** * Test for get_defined_custom_rule_descriptions(). * - * @covers \mod_questionnaire\completion\custom_completion + * @covers \mod_questionnaire\completion\custom_completion::get_custom_rule_descriptions */ public function test_get_custom_rule_descriptions() { // Get defined custom rules. @@ -156,7 +156,7 @@ public function test_get_custom_rule_descriptions() { /** * Test for is_defined(). * - * @covers \mod_questionnaire\completion\custom_completion + * @covers \mod_questionnaire\completion\custom_completion::is_defined */ public function test_is_defined() { // Build a mock cm_info instance. diff --git a/tests/fixtures/testfilequestion.pdf b/tests/fixtures/testfilequestion.pdf new file mode 100644 index 00000000..90588c30 Binary files /dev/null and b/tests/fixtures/testfilequestion.pdf differ diff --git a/tests/fixtures/testfilequestion2.pdf b/tests/fixtures/testfilequestion2.pdf new file mode 100644 index 00000000..90588c30 Binary files /dev/null and b/tests/fixtures/testfilequestion2.pdf differ diff --git a/tests/generator_test.php b/tests/generator_test.php index 707ecf6a..a29f4c56 100644 --- a/tests/generator_test.php +++ b/tests/generator_test.php @@ -14,6 +14,8 @@ // You should have received a copy of the GNU General Public License // along with Moodle. If not, see . +namespace mod_questionnaire; + /** * PHPUnit questionnaire generator tests * @@ -23,21 +25,11 @@ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ -namespace mod_questionnaire; - -/** - * Unit tests for questionnaire_generator_testcase. - * @group mod_questionnaire - */ class generator_test extends \advanced_testcase { /** - * Test generator create_instance function. + * Test case for the create_instance function. * - * @return void - * @throws coding_exception - * @throws dml_exception - * - * @covers \mod_questionnaire\generator\ + * @covers \mod_questionnaire_generator::create_instance */ public function test_create_instance() { global $DB; @@ -74,13 +66,9 @@ public function test_create_instance() { } /** - * Test generator create_content function. - * - * @return void - * @throws coding_exception - * @throws dml_exception + * Test case for the create_content function. * - * @covers \mod_questionnaire\generator\ + * @covers \mod_questionnaire_generator::create_content */ public function test_create_content() { global $DB; diff --git a/tests/lib_test.php b/tests/lib_test.php index 4f828306..20dd4a06 100644 --- a/tests/lib_test.php +++ b/tests/lib_test.php @@ -14,15 +14,6 @@ // You should have received a copy of the GNU General Public License // along with Moodle. If not, see . -/** - * PHPUnit questionnaire generator tests - * - * @package mod_questionnaire - * @copyright 2015 Mike Churchward (mike@churchward.ca) - * @author Mike Churchward - * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later - */ - namespace mod_questionnaire; use mod_questionnaire\question\question; @@ -34,17 +25,19 @@ require_once($CFG->dirroot.'/mod/questionnaire/classes/question/question.php'); /** - * Unit tests for questionnaire_lib_testcase. - * @group mod_questionnaire + * PHPUnit questionnaire lib tests + * + * @package mod_questionnaire + * @copyright 2015 Mike Churchward (mike@churchward.ca) + * @author Mike Churchward + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class lib_test extends \advanced_testcase { /** - * Test for questionnaire_supports. + * Test case for the questionnaire_supports function. * - * @return void - * - * @covers \questionnaire_supports + * @covers ::questionnaire_supports */ public function test_questionnaire_supports() { $this->assertTrue(questionnaire_supports(FEATURE_BACKUP_MOODLE2)); @@ -60,11 +53,9 @@ public function test_questionnaire_supports() { } /** - * Test for questionnaire_get_extra_capabilities. - * - * @return void + * Test case for the questionnaire_get_extra_capabilities function. * - * @covers \questionnaire_get_extra_capabilities + * @covers ::questionnaire_get_extra_capabilities */ public function test_questionnaire_get_extra_capabilities() { $caps = questionnaire_get_extra_capabilities(); @@ -74,12 +65,9 @@ public function test_questionnaire_get_extra_capabilities() { } /** - * Test for questionnaire_add_instance. + * Test case for the questionnaire_add_instance function. * - * @return void - * @throws moodle_exception - * - * @covers \questionnaire_add_instance + * @covers ::questionnaire_add_instance */ public function test_add_instance() { $this->resetAfterTest(); @@ -112,12 +100,9 @@ public function test_add_instance() { } /** - * Test for questionnaire_update_instance(). - * - * @return void - * @throws dml_exception + * Test case for the questionnaire_update_instance function. * - * @covers \questionnaire_update_instance + * @covers ::questionnaire_update_instance */ public function test_update_instance() { global $DB; @@ -176,13 +161,9 @@ public function test_update_instance() { /** * Test for questionnaire_delete_instance(). - * * Need to verify that delete_instance deletes all data associated with a questionnaire. * - * @return void - * @throws dml_exception - * - * @covers \questionnaire_delete_instance + * @covers ::questionnaire_delete_instance */ public function test_delete_instance() { global $DB; @@ -216,12 +197,9 @@ public function test_delete_instance() { } /** - * Test for questionnaire_user_outline(). - * - * @return void - * @throws coding_exception + * Test case for the questionnaire_user_outline function. * - * @covers \questionnaire_user_outline + * @covers ::questionnaire_user_outline */ public function test_questionnaire_user_outline() { $this->resetAfterTest(); @@ -244,12 +222,9 @@ public function test_questionnaire_user_outline() { } /** - * Test for questionnaire_user_complete(). + * Test case for the questionnaire_user_complete function. * - * @return void - * @throws coding_exception - * - * @covers \questionnaire_user_complete + * @covers ::questionnaire_user_complete */ public function test_questionnaire_user_complete() { $this->resetAfterTest(); @@ -264,11 +239,9 @@ public function test_questionnaire_user_complete() { } /** - * Test for questionnaire_print_recent_activity(). - * - * @return void + * Test case for the questionnaire_print_recent_activity function. * - * @covers \questionnaire_print_recent_activity + * @covers ::questionnaire_print_recent_activity */ public function test_questionnaire_print_recent_activity() { $this->resetAfterTest(); @@ -277,11 +250,9 @@ public function test_questionnaire_print_recent_activity() { } /** - * Test for questionnaire_grades(). - * - * @return void + * Test case for the questionnaire_grades function. * - * @covers \questionnaire_grades + * @covers ::questionnaire_grades */ public function test_questionnaire_grades() { $this->resetAfterTest(); @@ -290,11 +261,9 @@ public function test_questionnaire_grades() { } /** - * Test for questionnaire_get_user_grades(). + * Test case for the questionnaire_get_user_grades function. * - * @return void - * - * @covers \questionnaire_get_user_grades + * @covers ::questionnaire_get_user_grades */ public function test_questionnaire_get_user_grades() { $this->resetAfterTest(); @@ -315,11 +284,9 @@ public function test_questionnaire_get_user_grades() { } /** - * Test for questionnaire_update_grades(). - * - * @return void + * Test case for the questionnaire_update_grades function. * - * @covers \questionnaire_update_grades + * @covers ::questionnaire_update_grades */ public function test_questionnaire_update_grades() { // Don't know how to test this yet! It doesn't return anything. @@ -327,11 +294,9 @@ public function test_questionnaire_update_grades() { } /** - * Test for questionnaire_grade_item_update(). - * - * @return void + * Test case for the questionnaire_grade_item_update function. * - * @covers \questionnaire_grade_item_update + * @covers ::questionnaire_grade_item_update */ public function test_questionnaire_grade_item_update() { $this->resetAfterTest(); diff --git a/tests/privacy_provider_test.php b/tests/privacy_provider_test.php index 0cdd6122..5b36d478 100644 --- a/tests/privacy_provider_test.php +++ b/tests/privacy_provider_test.php @@ -14,14 +14,7 @@ // You should have received a copy of the GNU General Public License // along with Moodle. If not, see . -/** - * Privacy test for the mod questionnaire. - * - * @package mod_questionnaire - * @copyright 2019, onwards Poet - * @author Mike Churchward - * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later - */ +namespace mod_questionnaire; namespace mod_questionnaire; diff --git a/tests/questiontypes_test.php b/tests/questiontypes_test.php index 53944a09..dc569508 100644 --- a/tests/questiontypes_test.php +++ b/tests/questiontypes_test.php @@ -14,15 +14,6 @@ // You should have received a copy of the GNU General Public License // along with Moodle. If not, see . -/** - * PHPUnit questionnaire generator tests - * - * @package mod_questionnaire - * @copyright 2015 Mike Churchward (mike@churchward.ca) - * @author Mike Churchward - * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later - */ - namespace mod_questionnaire; use mod_questionnaire\question\question; @@ -34,17 +25,19 @@ require_once($CFG->dirroot . '/mod/questionnaire/classes/question/question.php'); /** - * Unit tests for questionnaire_questiontypes_testcase. - * @group mod_questionnaire + * PHPUnit questionnaire questiontypes tests + * + * @package mod_questionnaire + * @copyright 2015 Mike Churchward (mike@churchward.ca) + * @author Mike Churchward + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class questiontypes_test extends \advanced_testcase { /** - * Create a check boxes test question. + * Test case for the create_test_question_with_choices function for checkbox questions. * - * @return void - * - * @covers \mod_questionnaire\questiontypes_test::create_test_question + * @covers ::create_test_question_with_choices */ public function test_create_question_checkbox() { $this->create_test_question_with_choices(QUESCHECK, @@ -52,33 +45,27 @@ public function test_create_question_checkbox() { } /** - * Create a date test question. - * - * @return void + * Test case for the create_test_question function for date questions. * - * @covers \mod_questionnaire\questiontypes_test::create_test_question + * @covers ::create_test_question */ public function test_create_question_date() { $this->create_test_question(QUESDATE, '\\mod_questionnaire\\question\\date', array('content' => 'Enter a date')); } /** - * Create a dropdown box test question. - * - * @return void + * Test case for the create_test_question_with_choices function for dropdown questions. * - * @covers \mod_questionnaire\questiontypes_test::create_test_question + * @covers ::create_test_question_with_choices */ public function test_create_question_dropdown() { $this->create_test_question_with_choices(QUESDROP, '\\mod_questionnaire\\question\\drop', array('content' => 'Select one')); } /** - * Create an essay test question. + * Test case for the create_test_question function for essay questions. * - * @return void - * - * @covers \mod_questionnaire\questiontypes_test::create_test_question + * @covers ::create_test_question */ public function test_create_question_essay() { $questiondata = array( @@ -89,11 +76,9 @@ public function test_create_question_essay() { } /** - * Create a sectiontext test question. - * - * @return void + * Test case for the create_test_question function for sectiontext questions. * - * @covers \mod_questionnaire\questiontypes_test::create_test_question + * @covers ::create_test_question */ public function test_create_question_sectiontext() { $this->create_test_question(QUESSECTIONTEXT, '\\mod_questionnaire\\question\\sectiontext', @@ -101,11 +86,9 @@ public function test_create_question_sectiontext() { } /** - * Create a numerical test question. - * - * @return void + * Test case for the create_test_question function for numeric questions. * - * @covers \mod_questionnaire\questiontypes_test::create_test_question + * @covers ::create_test_question */ public function test_create_question_numeric() { $questiondata = array( @@ -116,11 +99,9 @@ public function test_create_question_numeric() { } /** - * Create a radio test question. + * Test case for the create_test_question_with_choices function for radiobuttons questions. * - * @return void - * - * @covers \mod_questionnaire\questiontypes_test::create_test_question + * @covers ::create_test_question_with_choices */ public function test_create_question_radiobuttons() { $this->create_test_question_with_choices(QUESRADIO, @@ -128,22 +109,18 @@ public function test_create_question_radiobuttons() { } /** - * Create a rate test question. - * - * @return void + * Test case for the create_test_question_with_choices function for ratescale questions. * - * @covers \mod_questionnaire\questiontypes_test::create_test_question + * @covers ::create_test_question_with_choices */ public function test_create_question_ratescale() { $this->create_test_question_with_choices(QUESRATE, '\\mod_questionnaire\\question\\rate', array('content' => 'Rate these')); } /** - * Create a text test question. - * - * @return void + * Test case for the create_test_question function for textbox questions. * - * @covers \mod_questionnaire\questiontypes_test::create_test_question + * @covers ::create_test_question */ public function test_create_question_textbox() { $questiondata = array( @@ -154,11 +131,9 @@ public function test_create_question_textbox() { } /** - * Create a slider test question. + * Test case for the create_test_question function for slider questions. * - * @return void - * - * @covers \mod_questionnaire\questiontypes_test::create_test_question + * @covers ::create_test_question */ public function test_create_question_slider() { $questiondata = array( @@ -167,17 +142,14 @@ public function test_create_question_slider() { } /** - * Create a yes/no test question. - * - * @return void + * Test case for the create_test_question function for yesno questions. * - * @covers \mod_questionnaire\questiontypes_test::create_test_question + * @covers ::create_test_question */ public function test_create_question_yesno() { $this->create_test_question(QUESYESNO, '\\mod_questionnaire\\question\\yesno', array('content' => 'Enter yes or no')); } - // General tests to call from specific tests above. /** diff --git a/tests/responsetypes_test.php b/tests/responsetypes_test.php index e10db5ef..d7f65c68 100644 --- a/tests/responsetypes_test.php +++ b/tests/responsetypes_test.php @@ -14,19 +14,8 @@ // You should have received a copy of the GNU General Public License // along with Moodle. If not, see . -/** - * PHPUnit questionnaire generator tests - * - * @package mod_questionnaire - * @copyright 2015 Mike Churchward (mike@churchward.ca) - * @author Mike Churchward - * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later - */ - namespace mod_questionnaire; -use mod_questionnaire\question\question; - defined('MOODLE_INTERNAL') || die(); global $CFG; @@ -36,17 +25,18 @@ require_once($CFG->dirroot . '/mod/questionnaire/classes/question/question.php'); /** - * Unit tests for questionnaire_responsetypes_testcase. - * @group mod_questionnaire + * PHPUnit questionnaire generator tests + * + * @package mod_questionnaire + * @copyright 2015 Mike Churchward (mike@churchward.ca) + * @author Mike Churchward + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class responsetypes_test extends \advanced_testcase { /** - * Test responses in a yes/no question. - * - * @return void - * @throws dml_exception + * Test case for the create_response_boolean function. * - * @covers \mod_questionnaire\question\yesno + * @covers \mod_questionnaire_generator::create_question_response */ public function test_create_response_boolean() { global $DB; @@ -75,12 +65,9 @@ public function test_create_response_boolean() { } /** - * Test responses in a essay question. - * - * @return void - * @throws dml_exception + * Test case for the create_response_text function. * - * @covers \mod_questionnaire\question\essay + * @covers \mod_questionnaire_generator::create_question_response */ public function test_create_response_text() { global $DB; @@ -110,12 +97,9 @@ public function test_create_response_text() { } /** - * Test responses in a slider question. + * Test case for the create_response_slider function. * - * @return void - * @throws dml_exception - * - * @covers \mod_questionnaire\question\slider + * @covers \mod_questionnaire_generator::create_question_response */ public function test_create_response_slider() { global $DB; @@ -145,12 +129,9 @@ public function test_create_response_slider() { } /** - * Test responses in a date question. - * - * @return void - * @throws dml_exception + * Test case for the create_response_date function. * - * @covers \mod_questionnaire\question\date + * @covers \mod_questionnaire_generator::create_question_response */ public function test_create_response_date() { global $DB; @@ -181,12 +162,9 @@ public function test_create_response_date() { } /** - * Test responses in a single choice radio question. - * - * @return void - * @throws dml_exception + * Test case for the create_response_single function. * - * @covers \mod_questionnaire\question\radio + * @covers \mod_questionnaire_generator::create_question_response */ public function test_create_response_single() { global $DB; @@ -258,12 +236,9 @@ public function test_create_response_single() { } /** - * Test responses in a multiple choices question. + * Test case for the create_response_multiple function. * - * @return void - * @throws dml_exception - * - * @covers \mod_questionnaire\question\rate + * @covers \mod_questionnaire_generator::create_question_response */ public function test_create_response_multiple() { global $DB; @@ -321,12 +296,9 @@ public function test_create_response_multiple() { } /** - * Test response's ranks in a rate question. - * - * @return void - * @throws dml_exception + * Test case for the create_response_rank function. * - * @covers \mod_questionnaire\question\rate + * @covers \mod_questionnaire_generator::create_question_response */ public function test_create_response_rank() { global $DB; diff --git a/version.php b/version.php index ac5eb9bf..84f51c30 100644 --- a/version.php +++ b/version.php @@ -25,10 +25,10 @@ defined('MOODLE_INTERNAL') || die(); -$plugin->version = 2022121600.02; // The current module version (Date: YYYYMMDDXX). +$plugin->version = 2023101501; // The current module version (Date: YYYYMMDDXX). $plugin->requires = 2022112800.00; // Moodle version (4.1.0). $plugin->component = 'mod_questionnaire'; -$plugin->release = '4.1.0 (Build - 2023081100)'; +$plugin->release = '4.1.1 (Build - 2023101501)'; $plugin->maturity = MATURITY_STABLE;