diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cb5726bee..83416f3ca 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,7 +7,7 @@ jobs: services: postgres: - image: postgres:15 + image: postgres:16 env: POSTGRES_USER: 'postgres' POSTGRES_HOST_AUTH_METHOD: 'trust' @@ -16,7 +16,7 @@ jobs: options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 3 mariadb: - image: mariadb:10 + image: mariadb:10.11 env: MYSQL_USER: 'root' MYSQL_ALLOW_EMPTY_PASSWORD: "true" @@ -34,7 +34,7 @@ jobs: moodle-branch: 'main' database: 'mariadb' - php: '8.4' - moodle-branch: 'MOODLE_501_STABLE' + moodle-branch: 'MOODLE_502_STABLE' database: 'pgsql' - php: '8.3' moodle-branch: 'MOODLE_501_STABLE' diff --git a/backup/moodle2/backup_questionnaire_stepslib.php b/backup/moodle2/backup_questionnaire_stepslib.php index 3d5b4974d..75c4e4f38 100644 --- a/backup/moodle2/backup_questionnaire_stepslib.php +++ b/backup/moodle2/backup_questionnaire_stepslib.php @@ -42,8 +42,8 @@ protected function define_structure() { 'introformat', 'qtype', 'respondenttype', - 'resp_eligible', - 'resp_view', + 'respeligible', + 'respview', 'notifications', 'opendate', 'closedate', @@ -69,13 +69,13 @@ protected function define_structure() { 'subtitle', 'info', 'theme', - 'thanks_page', - 'thank_head', - 'thank_body', + 'thankspage', + 'thankhead', + 'thankbody', 'feedbacksections', 'feedbacknotes', 'feedbackscores', - 'chart_type', + 'charttype', ]); $questions = new backup_nested_element('questions'); @@ -83,8 +83,8 @@ protected function define_structure() { $question = new backup_nested_element('question', ['id'], [ 'surveyid', 'name', - 'type_id', - 'result_id', + 'typeid', + 'resultid', 'length', 'precise', 'position', @@ -96,7 +96,7 @@ protected function define_structure() { $questchoices = new backup_nested_element('quest_choices'); - $questchoice = new backup_nested_element('quest_choice', ['id'], ['question_id', 'content', 'value']); + $questchoice = new backup_nested_element('quest_choice', ['id'], ['questionid', 'content', 'value']); $questdependencies = new backup_nested_element('quest_dependencies'); @@ -143,53 +143,53 @@ protected function define_structure() { $responsebools = new backup_nested_element('response_bools'); - $responsebool = new backup_nested_element('response_bool', ['id'], ['response_id', 'question_id', 'choice_id']); + $responsebool = new backup_nested_element('response_bool', ['id'], ['responseid', 'questionid', 'choiceid']); $responsedates = new backup_nested_element('response_dates'); - $responsedate = new backup_nested_element('response_date', ['id'], ['response_id', 'question_id', 'response']); + $responsedate = new backup_nested_element('response_date', ['id'], ['responseid', 'questionid', 'response']); $responsefiles = new backup_nested_element('response_files'); - $responsefile = new backup_nested_element('response_file', ['id'], ['response_id', 'question_id', 'fileid']); + $responsefile = new backup_nested_element('response_file', ['id'], ['responseid', 'questionid', 'fileid']); $responsemultiples = new backup_nested_element('response_multiples'); $responsemultiple = new backup_nested_element('response_multiple', ['id'], [ - 'response_id', - 'question_id', - 'choice_id', + 'responseid', + 'questionid', + 'choiceid', ]); $responseothers = new backup_nested_element('response_others'); $responseother = new backup_nested_element('response_other', ['id'], [ - 'response_id', - 'question_id', - 'choice_id', + 'responseid', + 'questionid', + 'choiceid', 'response', ]); $responseranks = new backup_nested_element('response_ranks'); $responserank = new backup_nested_element('response_rank', ['id'], [ - 'response_id', - 'question_id', - 'choice_id', + 'responseid', + 'questionid', + 'choiceid', 'rankvalue', ]); $responsesingles = new backup_nested_element('response_singles'); $responsesingle = new backup_nested_element('response_single', ['id'], [ - 'response_id', - 'question_id', - 'choice_id', + 'responseid', + 'questionid', + 'choiceid', ]); $responsetexts = new backup_nested_element('response_texts'); - $responsetext = new backup_nested_element('response_text', ['id'], ['response_id', 'question_id', 'response']); + $responsetext = new backup_nested_element('response_text', ['id'], ['responseid', 'questionid', 'response']); // Build the tree. $questionnaire->add_child($surveys); @@ -255,21 +255,21 @@ protected function define_structure() { $question->set_source_table('questionnaire_question', ['surveyid' => backup::VAR_PARENTID]); $fbsection->set_source_table('questionnaire_fb_sections', ['surveyid' => backup::VAR_PARENTID]); $feedback->set_source_table('questionnaire_feedback', ['sectionid' => backup::VAR_PARENTID]); - $questchoice->set_source_table('questionnaire_quest_choice', ['question_id' => backup::VAR_PARENTID], 'id ASC'); + $questchoice->set_source_table('questionnaire_quest_choice', ['questionid' => backup::VAR_PARENTID], 'id ASC'); $questdependency->set_source_table('questionnaire_dependency', ['questionid' => backup::VAR_PARENTID]); // All the rest of elements only happen if we are including user info. if ($userinfo) { $response->set_source_table('questionnaire_response', ['questionnaireid' => backup::VAR_PARENTID]); - $responsebool->set_source_table('questionnaire_response_bool', ['response_id' => backup::VAR_PARENTID]); - $responsedate->set_source_table('questionnaire_response_date', ['response_id' => backup::VAR_PARENTID]); - $responsefile->set_source_table('questionnaire_response_file', ['response_id' => backup::VAR_PARENTID]); + $responsebool->set_source_table('questionnaire_response_bool', ['responseid' => backup::VAR_PARENTID]); + $responsedate->set_source_table('questionnaire_response_date', ['responseid' => backup::VAR_PARENTID]); + $responsefile->set_source_table('questionnaire_response_file', ['responseid' => backup::VAR_PARENTID]); $responsefile->annotate_files('mod_questionnaire', 'response_file', null); - $responsemultiple->set_source_table('questionnaire_resp_multiple', ['response_id' => backup::VAR_PARENTID]); - $responseother->set_source_table('questionnaire_response_other', ['response_id' => backup::VAR_PARENTID]); - $responserank->set_source_table('questionnaire_response_rank', ['response_id' => backup::VAR_PARENTID]); - $responsesingle->set_source_table('questionnaire_resp_single', ['response_id' => backup::VAR_PARENTID]); - $responsetext->set_source_table('questionnaire_response_text', ['response_id' => backup::VAR_PARENTID]); + $responsemultiple->set_source_table('questionnaire_resp_multiple', ['responseid' => backup::VAR_PARENTID]); + $responseother->set_source_table('questionnaire_response_other', ['responseid' => backup::VAR_PARENTID]); + $responserank->set_source_table('questionnaire_response_rank', ['responseid' => backup::VAR_PARENTID]); + $responsesingle->set_source_table('questionnaire_resp_single', ['responseid' => backup::VAR_PARENTID]); + $responsetext->set_source_table('questionnaire_response_text', ['responseid' => backup::VAR_PARENTID]); } // Define id annotations. diff --git a/backup/moodle2/restore_questionnaire_activity_task.class.php b/backup/moodle2/restore_questionnaire_activity_task.class.php index 36386eb5e..f94de10e6 100644 --- a/backup/moodle2/restore_questionnaire_activity_task.class.php +++ b/backup/moodle2/restore_questionnaire_activity_task.class.php @@ -52,7 +52,7 @@ public static function define_decode_contents() { $contents[] = new restore_decode_content('questionnaire', ['intro'], 'questionnaire'); $contents[] = new restore_decode_content( 'questionnaire_survey', - ['info', 'thank_head', 'thank_body', 'thanks_page', 'feedbacknotes'], + ['info', 'thankhead', 'thankbody', 'thankspage', 'feedbacknotes'], 'questionnaire_survey' ); $contents[] = new restore_decode_content('questionnaire_question', ['content'], 'questionnaire_question'); diff --git a/backup/moodle2/restore_questionnaire_stepslib.php b/backup/moodle2/restore_questionnaire_stepslib.php index 430431325..8fb9eb76b 100644 --- a/backup/moodle2/restore_questionnaire_stepslib.php +++ b/backup/moodle2/restore_questionnaire_stepslib.php @@ -14,7 +14,7 @@ // You should have received a copy of the GNU General Public License // along with Moodle. If not, see . -use mod_questionnaire\feedback\section; +use mod_questionnaire\local\feedback\section; /** * Define all the restore steps that will be used by the restore_questionnaire_activity_task. @@ -177,6 +177,16 @@ protected function process_questionnaire($data) { $data->closedate = $this->apply_date_offset($data->closedate); $data->timemodified = $this->apply_date_offset($data->timemodified); + // Convert renamed fields if necessary. + if (isset($data->resp_eligible)) { + $data->respeligible = $data->resp_eligible; + unset($data->resp_eligible); + } + if (isset($data->resp_view)) { + $data->respview = $data->resp_view; + unset($data->resp_view); + } + // Insert the questionnaire record. $newitemid = $DB->insert_record('questionnaire', $data); // Immediately after inserting "activity" record, call this. @@ -199,6 +209,24 @@ protected function process_questionnaire_survey($data) { $data->feedbacksections = 2; } + // Convert renamed fields if necessary. + if (isset($data->thanks_page)) { + $data->thankspage = $data->thanks_page; + unset($data->thanks_page); + } + if (isset($data->thank_body)) { + $data->thankbody = $data->thank_body; + unset($data->thank_body); + } + if (isset($data->thank_head)) { + $data->thankhead = $data->thank_head; + unset($data->thank_head); + } + if (isset($data->chart_type)) { + $data->charttype = $data->chart_type; + unset($data->chart_type); + } + // Insert the questionnaire_survey record. $newitemid = $DB->insert_record('questionnaire_survey', $data); $this->set_mapping('questionnaire_survey', $oldid, $newitemid, true); @@ -224,6 +252,17 @@ protected function process_questionnaire_question($data) { // Question was deleted, so don't restore it. return; } + + // Convert renamed fields if necessary. + if (isset($data->type_id)) { + $data->typeid = $data->type_id; + unset($data->type_id); + } + if (isset($data->result_id)) { + $data->resultid = $data->result_id; + unset($data->result_id); + } + // Insert the questionnaire_question record. $newitemid = $DB->insert_record('questionnaire_question', $data); $this->set_mapping('questionnaire_question', $oldid, $newitemid, true); @@ -289,8 +328,6 @@ protected function process_questionnaire_quest_choice($data) { $data = (object)$data; - require_once($CFG->dirroot . '/mod/questionnaire/locallib.php'); - // Some old systems had '' instead of NULL. Change it to NULL. if ($data->value === '') { $data->value = null; @@ -299,14 +336,20 @@ protected function process_questionnaire_quest_choice($data) { // Replace the = separator with :: separator in quest_choice content. // This fixes radio button options using old "value"="display" formats. if (($data->value == null || $data->value == 'NULL') && !preg_match("/^([0-9]{1,3}=.*|!other=.*)$/", $data->content)) { - $content = questionnaire_choice_values($data->content); + $content = \mod_questionnaire\local\question\question::parse_choice_content($data->content); if (strpos($content->text, '=')) { $data->content = str_replace('=', '::', $content->text); } } + // Convert renamed fields if necessary. + if (isset($data->question_id)) { + $data->questionid = $data->question_id; + unset($data->question_id); + } + $oldid = $data->id; - $data->question_id = $this->get_new_parentid('questionnaire_question'); + $data->questionid = $this->get_new_parentid('questionnaire_question'); // Insert the questionnaire_quest_choice record. $newitemid = $DB->insert_record('questionnaire_quest_choice', $data); @@ -370,14 +413,29 @@ protected function process_questionnaire_response_bool($data) { global $DB; $data = (object)$data; - $data->response_id = $this->get_new_parentid('questionnaire_response'); - $data->question_id = $this->get_mappingid('questionnaire_question', $data->question_id); + + // Convert renamed fields if necessary. + if (isset($data->response_id)) { + $data->responseid = $data->response_id; + unset($data->response_id); + } + if (isset($data->question_id)) { + $data->questionid = $data->question_id; + unset($data->question_id); + } + if (isset($data->choice_id)) { + $data->choiceid = $data->choice_id; + unset($data->choice_id); + } + + $data->responseid = $this->get_new_parentid('questionnaire_response'); + $data->questionid = $this->get_mappingid('questionnaire_question', $data->questionid); // Insert the questionnaire_response_bool record. $DB->insert_record('questionnaire_response_bool', $data); } - /** + /**` * Process date responses. * @param array $data * @throws dml_exception @@ -386,8 +444,19 @@ protected function process_questionnaire_response_date($data) { global $DB; $data = (object)$data; - $data->response_id = $this->get_new_parentid('questionnaire_response'); - $data->question_id = $this->get_mappingid('questionnaire_question', $data->question_id); + + // Convert renamed fields if necessary. + if (isset($data->response_id)) { + $data->responseid = $data->response_id; + unset($data->response_id); + } + if (isset($data->question_id)) { + $data->questionid = $data->question_id; + unset($data->question_id); + } + + $data->responseid = $this->get_new_parentid('questionnaire_response'); + $data->questionid = $this->get_mappingid('questionnaire_question', $data->questionid); // Insert the questionnaire_response_date record. $DB->insert_record('questionnaire_response_date', $data); @@ -402,8 +471,19 @@ protected function process_questionnaire_response_file($data) { global $DB; $data = (object)$data; - $data->response_id = $this->get_new_parentid('questionnaire_response'); - $data->question_id = $this->get_mappingid('questionnaire_question', $data->question_id); + + // Convert renamed fields if necessary. + if (isset($data->response_id)) { + $data->responseid = $data->response_id; + unset($data->response_id); + } + if (isset($data->question_id)) { + $data->questionid = $data->question_id; + unset($data->question_id); + } + + $data->responseid = $this->get_new_parentid('questionnaire_response'); + $data->questionid = $this->get_mappingid('questionnaire_question', $data->questionid); // Insert the questionnaire_response_file record. $newid = $DB->insert_record('questionnaire_response_file', $data); $this->responsefileids[$newid] = $data->id; @@ -418,9 +498,24 @@ protected function process_questionnaire_response_multiple($data) { global $DB; $data = (object)$data; - $data->response_id = $this->get_new_parentid('questionnaire_response'); - $data->question_id = $this->get_mappingid('questionnaire_question', $data->question_id); - $data->choice_id = $this->get_mappingid('questionnaire_quest_choice', $data->choice_id); + + // Convert renamed fields if necessary. + if (isset($data->response_id)) { + $data->responseid = $data->response_id; + unset($data->response_id); + } + if (isset($data->question_id)) { + $data->questionid = $data->question_id; + unset($data->question_id); + } + if (isset($data->choice_id)) { + $data->choiceid = $data->choice_id; + unset($data->choice_id); + } + + $data->responseid = $this->get_new_parentid('questionnaire_response'); + $data->questionid = $this->get_mappingid('questionnaire_question', $data->questionid); + $data->choiceid = $this->get_mappingid('questionnaire_quest_choice', $data->choiceid); // Insert the questionnaire_resp_multiple record. $DB->insert_record('questionnaire_resp_multiple', $data); @@ -435,9 +530,24 @@ protected function process_questionnaire_response_other($data) { global $DB; $data = (object)$data; - $data->response_id = $this->get_new_parentid('questionnaire_response'); - $data->question_id = $this->get_mappingid('questionnaire_question', $data->question_id); - $data->choice_id = $this->get_mappingid('questionnaire_quest_choice', $data->choice_id); + + // Convert renamed fields if necessary. + if (isset($data->response_id)) { + $data->responseid = $data->response_id; + unset($data->response_id); + } + if (isset($data->question_id)) { + $data->questionid = $data->question_id; + unset($data->question_id); + } + if (isset($data->choice_id)) { + $data->choiceid = $data->choice_id; + unset($data->choice_id); + } + + $data->responseid = $this->get_new_parentid('questionnaire_response'); + $data->questionid = $this->get_mappingid('questionnaire_question', $data->questionid); + $data->choiceid = $this->get_mappingid('questionnaire_quest_choice', $data->choiceid); // Insert the questionnaire_response_other record. $DB->insert_record('questionnaire_response_other', $data); @@ -453,14 +563,28 @@ protected function process_questionnaire_response_rank($data) { $data = (object)$data; + // Convert renamed fields if necessary. + if (isset($data->response_id)) { + $data->responseid = $data->response_id; + unset($data->response_id); + } + if (isset($data->question_id)) { + $data->questionid = $data->question_id; + unset($data->question_id); + } + if (isset($data->choice_id)) { + $data->choiceid = $data->choice_id; + unset($data->choice_id); + } + // Older versions of questionnaire used 'rank' instead of 'rankvalue'. If 'rank' exists, change it to 'rankvalue'. if (isset($data->rank) && !isset($data->rankvalue)) { $data->rankvalue = $data->rank; } - $data->response_id = $this->get_new_parentid('questionnaire_response'); - $data->question_id = $this->get_mappingid('questionnaire_question', $data->question_id); - $data->choice_id = $this->get_mappingid('questionnaire_quest_choice', $data->choice_id); + $data->responseid = $this->get_new_parentid('questionnaire_response'); + $data->questionid = $this->get_mappingid('questionnaire_question', $data->questionid); + $data->choiceid = $this->get_mappingid('questionnaire_quest_choice', $data->choiceid); // Insert the questionnaire_response_rank record. $DB->insert_record('questionnaire_response_rank', $data); @@ -475,9 +599,24 @@ protected function process_questionnaire_response_single($data) { global $DB; $data = (object)$data; - $data->response_id = $this->get_new_parentid('questionnaire_response'); - $data->question_id = $this->get_mappingid('questionnaire_question', $data->question_id); - $data->choice_id = $this->get_mappingid('questionnaire_quest_choice', $data->choice_id); + + // Convert renamed fields if necessary. + if (isset($data->response_id)) { + $data->responseid = $data->response_id; + unset($data->response_id); + } + if (isset($data->question_id)) { + $data->questionid = $data->question_id; + unset($data->question_id); + } + if (isset($data->choice_id)) { + $data->choiceid = $data->choice_id; + unset($data->choice_id); + } + + $data->responseid = $this->get_new_parentid('questionnaire_response'); + $data->questionid = $this->get_mappingid('questionnaire_question', $data->questionid); + $data->choiceid = $this->get_mappingid('questionnaire_quest_choice', $data->choiceid); // Insert the questionnaire_resp_single record. $DB->insert_record('questionnaire_resp_single', $data); @@ -491,8 +630,19 @@ protected function process_questionnaire_response_text($data) { global $DB; $data = (object)$data; - $data->response_id = $this->get_new_parentid('questionnaire_response'); - $data->question_id = $this->get_mappingid('questionnaire_question', $data->question_id); + + // Convert renamed fields if necessary. + if (isset($data->response_id)) { + $data->responseid = $data->response_id; + unset($data->response_id); + } + if (isset($data->question_id)) { + $data->questionid = $data->question_id; + unset($data->question_id); + } + + $data->responseid = $this->get_new_parentid('questionnaire_response'); + $data->questionid = $this->get_mappingid('questionnaire_question', $data->questionid); // Insert the questionnaire_response_text record. $DB->insert_record('questionnaire_response_text', $data); @@ -572,7 +722,7 @@ protected function after_execute() { $newrec->surveyid = $this->get_new_parentid('questionnaire_survey'); $newrec->dependquestionid = $this->get_mappingid('questionnaire_question', $olddependid); // Only change mapping for RADIO and DROP question types, not for YESNO question. - $dependqtype = $DB->get_field('questionnaire_question', 'type_id', ['id' => $newrec->dependquestionid]); + $dependqtype = $DB->get_field('questionnaire_question', 'typeid', ['id' => $newrec->dependquestionid]); if (($dependqtype !== false) && ($dependqtype != 1)) { $newrec->dependchoiceid = $this->get_mappingid( 'questionnaire_quest_choice', @@ -591,7 +741,7 @@ protected function after_execute() { $data->dependquestionid = $this->get_mappingid('questionnaire_question', $data->dependquestionid); // Only change mapping for RADIO and DROP question types, not for YESNO question. - $dependqtype = $DB->get_field('questionnaire_question', 'type_id', ['id' => $data->dependquestionid]); + $dependqtype = $DB->get_field('questionnaire_question', 'typeid', ['id' => $data->dependquestionid]); if (($dependqtype !== false) && ($dependqtype != 1)) { $data->dependchoiceid = $this->get_mappingid('questionnaire_quest_choice', $data->dependchoiceid); } @@ -610,7 +760,7 @@ protected function after_execute() { // Process any old rate question named degree choices after all questions and choices have been restored. if ($this->task->get_old_moduleversion() < 2018110103) { - \mod_questionnaire\question\rate::move_all_nameddegree_choices($this->get_new_parentid('questionnaire_survey')); + \mod_questionnaire\local\question\rate::move_all_nameddegree_choices($this->get_new_parentid('questionnaire_survey')); } } } diff --git a/classes/capabilities.php b/classes/capabilities.php new file mode 100644 index 000000000..6b1020539 --- /dev/null +++ b/classes/capabilities.php @@ -0,0 +1,367 @@ +. + +namespace mod_questionnaire; + +/** + * Permission and eligibility checks for a questionnaire. + * + * Holds a reference to a questionnaire and answers "may this user do X?" + * questions. Wraps Moodle has_capability() calls, plus the composite policy + * rules (response-view restrictions, attempt-frequency timing). + * + * @package mod_questionnaire + * @copyright 2026 onward Mike Churchward (mike.churchward@poetopensource.org) + * @author Mike Churchward + * @license http://www.gnu.org/copyleft/gpl.html GNU Public License + */ +class capabilities { + /** @var questionnaire The questionnaire whose permissions this object answers. */ + private questionnaire $questionnaire; + + /** + * Constructor. + * + * @param questionnaire $questionnaire + */ + public function __construct(questionnaire $questionnaire) { + $this->questionnaire = $questionnaire; + } + + /** + * True if the specified user is eligible to view and submit this questionnaire. + * + * @param int|null $userid Defaults to current user. + * @return bool + */ + public function user_is_eligible(?int $userid = null): bool { + $context = $this->questionnaire->context(); + return has_capability('mod/questionnaire:view', $context, $userid) && + has_capability('mod/questionnaire:submit', $context, $userid); + } + + /** + * True if the specified user can read their own responses. + * + * @param int|null $userid + * @return bool + */ + public function can_read_own_responses(?int $userid = null): bool { + return has_capability('mod/questionnaire:readownresponses', $this->questionnaire->context(), $userid); + } + + /** + * True if the specified user can view a single response. + * + * @param int|null $userid + * @return bool + */ + public function can_view_single_response(?int $userid = null): bool { + return has_capability('mod/questionnaire:viewsingleresponse', $this->questionnaire->context(), $userid); + } + + /** + * True if the specified user can delete responses. + * + * @param int|null $userid + * @return bool + */ + public function can_delete_responses(?int $userid = null): bool { + return has_capability('mod/questionnaire:deleteresponses', $this->questionnaire->context(), $userid); + } + + /** + * True if the specified user can download responses. + * + * @param int|null $userid + * @return bool + */ + public function can_download_responses(?int $userid = null): bool { + return has_capability('mod/questionnaire:downloadresponses', $this->questionnaire->context(), $userid); + } + + /** + * True if the specified user can manage this questionnaire. + * + * @param int|null $userid + * @return bool + */ + public function can_manage_questionnaire(?int $userid = null): bool { + return has_capability('mod/questionnaire:manage', $this->questionnaire->context(), $userid); + } + + /** + * True if the specified user can edit questions in this questionnaire. + * + * @param int|null $userid + * @return bool + */ + public function can_edit_questions(?int $userid = null): bool { + return has_capability('mod/questionnaire:editquestions', $this->questionnaire->context(), $userid); + } + + /** + * True if the current user can view this questionnaire. + * + * @return bool + */ + public function can_view(): bool { + return has_capability('mod/questionnaire:view', $this->questionnaire->context()); + } + + /** + * True if the current user can preview this questionnaire. + * + * @return bool + */ + public function can_preview(): bool { + return has_capability('mod/questionnaire:preview', $this->questionnaire->context()); + } + + /** + * True if the current user can print a blank copy of this questionnaire. + * + * Returns false in survey-only mode (no real module instance, so the print.php + * link cannot be built). + * + * @return bool + */ + public function can_print_blank(): bool { + if ($this->questionnaire->coursemodule() === null) { + return false; + } + return has_capability('mod/questionnaire:printblank', $this->questionnaire->context()); + } + + /** + * True if the current user can create template surveys. + * + * @return bool + */ + public function can_create_templates(): bool { + return has_capability('mod/questionnaire:createtemplates', $this->questionnaire->context()); + } + + /** + * True if the current user can create public surveys. + * + * @return bool + */ + public function can_create_public(): bool { + return has_capability('mod/questionnaire:createpublic', $this->questionnaire->context()); + } + + /** + * Return true if the current user has site-level access to all groups. + * + * @return bool + */ + public function can_view_all_groups(): bool { + return has_capability('moodle/site:accessallgroups', $this->questionnaire->context()); + } + + /** + * True if the specified user is allowed to take this questionnaire right now. + * + * @param int $userid + * @return bool + */ + public function user_can_take(int $userid): bool { + if (!$this->questionnaire->is_active() || !$this->user_is_eligible($userid)) { + return false; + } else if ($this->questionnaire->qtype() == questionnaire::QTYPE_UNLIMITED) { + return true; + } else if ($userid > 0) { + return $this->user_time_for_new_attempt($userid); + } else { + return false; + } + } + + /** + * True if the timing rules allow the user to start a new attempt. + * + * @param int $userid + * @return bool + */ + public function user_time_for_new_attempt(int $userid): bool { + $attempt = \mod_questionnaire\local\db\response_record::get_latest_complete_for_user( + $this->questionnaire->id(), + $userid + ); + if ($attempt === null) { + return true; + } + + $lastsubmitted = (int)$attempt->get('submitted'); + $timenow = time(); + + switch ($this->questionnaire->qtype()) { + case questionnaire::QTYPE_UNLIMITED: + return true; + + case questionnaire::QTYPE_ONCE: + return false; + + case questionnaire::QTYPE_DAILY: + return (date('Y', $lastsubmitted) < date('Y', $timenow)) || + (date('Yz', $lastsubmitted) < date('Yz', $timenow)); + + case questionnaire::QTYPE_WEEKLY: + return (date('Y', $lastsubmitted) < date('Y', $timenow)) || + (date('YW', $lastsubmitted) < date('YW', $timenow)); + + case questionnaire::QTYPE_MONTHLY: + return (date('Y', $lastsubmitted) < date('Y', $timenow)) || + (date('Yn', $lastsubmitted) < date('Yn', $timenow)); + + default: + return false; + } + } + + /** + * True if the current user can view all responses (checking group membership and response counts). + * + * @param int|null $usernumresp Number of responses the user has made; null to calculate. + * @param bool $isviewreport Whether the context is a view-report page. + * @return bool + */ + public function can_view_all_responses(?int $usernumresp = null, bool $isviewreport = false): bool { + global $USER; + + $numresp = $this->questionnaire->count_submissions(); + if ($usernumresp === null) { + $usernumresp = $this->questionnaire->count_submissions($USER->id); + } + + $context = $this->questionnaire->context(); + $cm = $this->questionnaire->coursemodule(); + $canviewallgroups = has_capability('moodle/site:accessallgroups', $context); + $groupmode = groups_get_activity_groupmode($cm, $this->questionnaire->courseid()); + $canviewgroups = ($groupmode == 1) + ? groups_has_membership($cm, $USER->id) + : true; + + $grouplogic = $canviewgroups || $canviewallgroups; + $respslogic = ($numresp > 0) || $isviewreport; + + return $this->can_view_all_responses_anytime($grouplogic, $respslogic) || + $this->can_view_all_responses_with_restrictions($usernumresp, $grouplogic, $respslogic); + } + + /** + * True if the user can view all responses at any time (no submission requirement). + * + * @param bool $grouplogic + * @param bool $respslogic + * @return bool + */ + public function can_view_all_responses_anytime(bool $grouplogic = true, bool $respslogic = true): bool { + return $grouplogic && $respslogic && $this->questionnaire->is_survey_owner() && + has_capability('mod/questionnaire:readallresponseanytime', $this->questionnaire->context()); + } + + /** + * True if the user can view all responses subject to the questionnaire's view restrictions. + * + * @param int|null $usernumresp + * @param bool $grouplogic + * @param bool $respslogic + * @return bool + */ + public function can_view_all_responses_with_restrictions( + ?int $usernumresp, + bool $grouplogic = true, + bool $respslogic = true + ): bool { + $respview = $this->questionnaire->respview(); + return $grouplogic && $respslogic && $this->questionnaire->is_survey_owner() && + has_capability('mod/questionnaire:readallresponses', $this->questionnaire->context()) && + ($respview == questionnaire::RESPVIEW_ALWAYS || + ($respview == questionnaire::RESPVIEW_WHENCLOSED && $this->questionnaire->is_closed()) || + ($respview == questionnaire::RESPVIEW_WHENANSWERED && $usernumresp)); + } + + /** + * True if the current user can view the specified response (or any response if $rid is 0). + * + * @param int $rid Response id to check, or 0 to check general viewing rights. + * @return bool + */ + public function can_view_response(int $rid = 0): bool { + global $USER, $DB; + + $context = $this->questionnaire->context(); + $respview = $this->questionnaire->respview(); + + if (!empty($rid)) { + $response = $DB->get_record('questionnaire_response', ['id' => $rid]); + + // Response not found or belongs to a different questionnaire. + if (empty($response) || $response->questionnaireid != $this->questionnaire->id()) { + return false; + } + + // Can always view if you have the unrestricted capability. + if (has_capability('mod/questionnaire:readallresponseanytime', $context)) { + return true; + } + + // Can view other users' responses if capability is set and view conditions are met. + if ( + has_capability('mod/questionnaire:readallresponses', $context) && + ($respview == questionnaire::RESPVIEW_ALWAYS || + ($respview == questionnaire::RESPVIEW_WHENCLOSED && $this->questionnaire->is_closed()) || + ($respview == questionnaire::RESPVIEW_WHENANSWERED && !$this->user_can_take($USER->id))) + ) { + return true; + } + + // Can view own response. + if ( + $response->userid == $USER->id && + has_capability('mod/questionnaire:readownresponses', $context) && + $this->questionnaire->count_submissions($USER->id) > 0 + ) { + return true; + } + } else { + // No specific response — check general viewing rights. + if (has_capability('mod/questionnaire:readallresponseanytime', $context)) { + return true; + } + + if ( + has_capability('mod/questionnaire:readallresponses', $context) && + ($respview == questionnaire::RESPVIEW_ALWAYS || + ($respview == questionnaire::RESPVIEW_WHENCLOSED && $this->questionnaire->is_closed()) || + ($respview == questionnaire::RESPVIEW_WHENANSWERED && !$this->user_can_take($USER->id))) + ) { + return true; + } + + if ( + has_capability('mod/questionnaire:readownresponses', $context) && + $this->questionnaire->count_submissions($USER->id) > 0 + ) { + return true; + } + } + + return false; + } +} diff --git a/classes/completion/custom_completion.php b/classes/completion/custom_completion.php index e946bb3af..93cf54d71 100644 --- a/classes/completion/custom_completion.php +++ b/classes/completion/custom_completion.php @@ -18,12 +18,9 @@ namespace mod_questionnaire\completion; -defined('MOODLE_INTERNAL') || die(); - -require_once($CFG->dirroot . '/mod/questionnaire/lib.php'); - use coding_exception; use core_completion\activity_custom_completion; +use mod_questionnaire\questionnaire; use moodle_exception; /** @@ -45,9 +42,8 @@ class custom_completion extends activity_custom_completion { */ public function get_state(string $rule): int { $this->validate_rule($rule); - $userid = $this->userid; - $cm = $this->cm; - $status = questionnaire_get_completion_state($cm, $userid, $rule); + $questionnaire = questionnaire::from_cm($this->cm); + $status = $questionnaire->completionsubmit() && $questionnaire->user_has_submitted($this->userid); return $status ? COMPLETION_COMPLETE : COMPLETION_INCOMPLETE; } diff --git a/classes/courseformat/overview.php b/classes/courseformat/overview.php index 8abb38091..38447b056 100644 --- a/classes/courseformat/overview.php +++ b/classes/courseformat/overview.php @@ -18,7 +18,7 @@ use cm_info; use core\output\pix_icon; -use mod_questionnaire\manager; +use mod_questionnaire\questionnaire; use core\activity_dates; use core\output\action_link; use core_calendar\output\humandate; @@ -34,10 +34,8 @@ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class overview extends \core_courseformat\activityoverviewbase { - /** - * @var manager the questionnaire manager. - */ - private manager $manager; + /** @var questionnaire The questionnaire domain object for this activity. */ + private questionnaire $questionnaire; /** * Constructor. @@ -51,7 +49,7 @@ public function __construct( protected readonly \core\output\renderer_helper $rendererhelper, ) { parent::__construct($cm); - $this->manager = manager::create_from_coursemodule($cm); + $this->questionnaire = questionnaire::from_cm($cm); } #[\Override] @@ -89,7 +87,7 @@ public function get_actions_overview(): ?overviewitem { return null; } - $currentanswerscount = $this->manager->count_all_users_answered(); + $currentanswerscount = $this->questionnaire->responses()->count_distinct_responders(); if ( class_exists(button::class) && @@ -129,11 +127,13 @@ public function get_extra_overview_items(): array { * @return overviewitem|null An overview item or null for teachers. */ private function get_extra_status_for_user(): ?overviewitem { + global $USER; + if (has_capability('mod/questionnaire:viewsingleresponse', $this->cm->context)) { return null; } - $status = $this->manager->has_answered(); + $status = $this->questionnaire->user_has_submitted($USER->id); $statustext = get_string('notanswered', 'questionnaire'); if ($status) { $statustext = get_string('answered', 'questionnaire'); @@ -170,7 +170,7 @@ private function get_extra_students_who_responded_overview(): ?overviewitem { } else { $groupids = []; } - $studentswhoresponded = $this->manager->count_all_users_answered($groupids); + $studentswhoresponded = $this->questionnaire->responses()->count_distinct_responders($groupids); return new overviewitem( name: get_string('studentwhoresponded', 'questionnaire'), diff --git a/classes/db/bulk_sql_config.php b/classes/db/bulk_sql_config.php index 8703dcf37..5591ba89a 100644 --- a/classes/db/bulk_sql_config.php +++ b/classes/db/bulk_sql_config.php @@ -71,7 +71,7 @@ public function __construct($table, $tablealias, $usechoiceid = false, $userespo */ public function get_extra_select() { return [ - 'choice_id' => $this->usechoiceid, + 'choiceid' => $this->usechoiceid, 'response' => $this->useresponse, 'rankvalue' => $this->userank, ]; diff --git a/classes/edit_question_form.php b/classes/edit_question_form.php index e7666b93d..e1bfbda42 100644 --- a/classes/edit_question_form.php +++ b/classes/edit_question_form.php @@ -45,9 +45,9 @@ public function definition() { // TODO - Is there a better way to do this without session global? // The 'sticky' required response value for further new questions. if (isset($SESSION->questionnaire->required) && !isset($question->qid)) { - $question->required = $SESSION->questionnaire->required; + $question->set_required_value($SESSION->questionnaire->required); } - if (!isset($question->type_id)) { + if ($question->typeid() === 0) { throw new \moodle_exception('undefinedquestiontype', 'mod_questionnaire'); } @@ -69,7 +69,7 @@ public function validation($data, $files) { $errors = parent::validation($data, $files); // If this is a rate question. - if ($data['type_id'] == QUESRATE) { + if ($data['typeid'] == QUESRATE) { if ($data['length'] < 2) { $errors["length"] = get_string('notenoughscaleitems', 'questionnaire'); } @@ -90,7 +90,7 @@ public function validation($data, $files) { } // If this is a slider question. - if ($data['type_id'] == QUESSLIDER) { + if ($data['typeid'] == QUESSLIDER) { if ( isset($data['minrange']) && isset($data['maxrange']) && diff --git a/classes/feedback_form.php b/classes/feedback_form.php index 3733fa81b..d877bceca 100644 --- a/classes/feedback_form.php +++ b/classes/feedback_form.php @@ -46,7 +46,7 @@ public function definition() { $feedbackoptions[2] = get_string('feedbacksections', 'questionnaire'); $mform->addElement('select', 'feedbacksections', get_string('feedbackoptions', 'questionnaire'), $feedbackoptions); - $mform->setDefault('feedbacksections', $questionnaire->survey->feedbacksections); + $mform->setDefault('feedbacksections', $questionnaire->survey()->feedbacksections()); $mform->addHelpButton('feedbacksections', 'feedbackoptions', 'questionnaire'); $options = ['0' => get_string('no'), '1' => get_string('yes')]; @@ -66,8 +66,8 @@ public function definition() { get_string('feedbackglobal', 'questionnaire') . ')', $charttypes ); - if ($questionnaire->survey->feedbacksections == 1) { - $mform->setDefault('chart_type_global', $questionnaire->survey->chart_type); + if ($questionnaire->survey()->feedbacksections() == 1) { + $mform->setDefault('chart_type_global', $questionnaire->survey()->charttype()); } $mform->disabledIf('chart_type_global', 'feedbacksections', 'eq', 0); $mform->disabledIf('chart_type_global', 'feedbacksections', 'neq', 1); @@ -83,8 +83,8 @@ public function definition() { get_string('feedbackbysection', 'questionnaire') . ')', $charttypes ); - if ($questionnaire->survey->feedbacksections > 1) { - $mform->setDefault('chart_type_two_sections', $questionnaire->survey->chart_type); + if ($questionnaire->survey()->feedbacksections() > 1) { + $mform->setDefault('chart_type_two_sections', $questionnaire->survey()->charttype()); } $mform->disabledIf('chart_type_two_sections', 'feedbacksections', 'neq', 2); @@ -100,8 +100,8 @@ public function definition() { get_string('feedbackbysection', 'questionnaire') . ')', $charttypes ); - if ($questionnaire->survey->feedbacksections > 1) { - $mform->setDefault('chart_type_sections', $questionnaire->survey->chart_type); + if ($questionnaire->survey()->feedbacksections() > 1) { + $mform->setDefault('chart_type_sections', $questionnaire->survey()->charttype()); } $mform->disabledIf('chart_type_sections', 'feedbacksections', 'eq', 0); $mform->disabledIf('chart_type_sections', 'feedbacksections', 'eq', 1); @@ -119,7 +119,7 @@ public function definition() { $editoroptions = ['maxfiles' => EDITOR_UNLIMITED_FILES, 'trusttext' => true]; $mform->addElement('editor', 'feedbacknotes', get_string('feedbacknotes', 'questionnaire'), null, $editoroptions); $mform->setType('feedbacknotes', PARAM_RAW); - $mform->setDefault('feedbacknotes', $questionnaire->survey->feedbacknotes); + $mform->setDefault('feedbacknotes', $questionnaire->survey()->feedbacknotes()); $mform->addHelpButton('feedbacknotes', 'feedbacknotes', 'questionnaire'); $mform->addElement('hidden', 'id', 0); diff --git a/classes/feedback_section_form.php b/classes/feedback_section_form.php index 88cc173b5..3321aea2e 100644 --- a/classes/feedback_section_form.php +++ b/classes/feedback_section_form.php @@ -21,7 +21,6 @@ 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 +37,21 @@ class feedback_section_form extends \moodleform { */ public $context; + /** @var \plugin_renderer_base The plugin renderer used by definition(). */ + protected \plugin_renderer_base $renderer; + + /** + * Constructor — accepts the standard moodleform args plus the plugin renderer. + * + * @param mixed $action + * @param mixed $customdata + * @param \plugin_renderer_base $renderer The plugin renderer. + */ + public function __construct($action, $customdata, \plugin_renderer_base $renderer) { + $this->renderer = $renderer; + parent::__construct($action, $customdata); + } + /** * Form definition. */ @@ -47,9 +61,9 @@ public function definition() { $feedbacksection = $this->_customdata->feedbacksection; $validquestions = $this->_customdata->validquestions; $survey = $this->_customdata->survey; - $feedbacksections = $questionnaire->survey->feedbacksections; + $feedbacksections = $questionnaire->survey()->feedbacksections(); $this->feedbacks = $feedbacksection->sectionfeedback; - $this->context = $questionnaire->context; + $this->context = $questionnaire->context(); $mform =& $this->_form; if ($survey->feedbacksections > 1) { @@ -106,10 +120,10 @@ public function definition() { $editoroptions ); $mform->setType('sectionheading', PARAM_RAW); - $mform->setDefault('feedbacknotes', $questionnaire->survey->feedbacknotes); + $mform->setDefault('feedbacknotes', $questionnaire->survey()->feedbacknotes()); $mform->addHelpButton('sectionheading', 'feedbackheading', 'questionnaire'); - if ($questionnaire->survey->feedbacksections > 0) { + if ($questionnaire->survey()->feedbacksections() > 0) { // Sections. if ($survey->feedbacksections > 1) { $mform->addElement( @@ -119,7 +133,7 @@ public function definition() { ); $qvalid = $validquestions; if (!empty($feedbacksection->scorecalculation)) { - $rsrc = $questionnaire->renderer->image_url('t/delete'); + $rsrc = $this->renderer->image_url('t/delete'); $strremove = get_string('remove', 'questionnaire'); $rextra = ['alt' => $strremove, 'title' => $strremove]; $counter = 1; @@ -139,7 +153,7 @@ public function definition() { unset($rextra['style']); $questionactions[] = $mform->createElement('image', 'confirmremovequestion[' . $qid . ']', $rsrc, $rextra); - $mform->addGroup($questionactions, '', $questionnaire->questions[$qid]->name); + $mform->addGroup($questionactions, '', $questionnaire->questions()[$qid]->name()); $counter++; } } @@ -173,7 +187,7 @@ public function definition() { 'feedbacktext', get_string('feedback', 'questionnaire'), null, - ['maxfiles' => EDITOR_UNLIMITED_FILES, 'noclean' => true, 'context' => $questionnaire->context] + ['maxfiles' => EDITOR_UNLIMITED_FILES, 'noclean' => true, 'context' => $questionnaire->context()] ); $repeatarray[] = $mform->createElement( 'text', @@ -205,7 +219,7 @@ public function definition() { "feedbacktext[$nextel]", get_string('feedback', 'questionnaire'), null, - ['maxfiles' => EDITOR_UNLIMITED_FILES, 'noclean' => true, 'context' => $questionnaire->context] + ['maxfiles' => EDITOR_UNLIMITED_FILES, 'noclean' => true, 'context' => $questionnaire->context()] ), 'boundary_add_fields' ); diff --git a/classes/feedback_settings_controller.php b/classes/feedback_settings_controller.php new file mode 100644 index 000000000..80a1cae01 --- /dev/null +++ b/classes/feedback_settings_controller.php @@ -0,0 +1,144 @@ +. + +namespace mod_questionnaire; + +use mod_questionnaire\local\feedback\section; + +/** + * Controller for the feedback.php save / redirect flow. + * + * Owns the conversion from feedback form data into an explicit allowlist of + * survey settings, the saving of the feedbacknotes editor draft area, and + * the bootstrap of an initial feedback section when the user chose to edit + * feedback sections immediately after saving. + * + * @package mod_questionnaire + * @copyright 2026 Mike Churchward (mike.churchward@poetopensource.org) + * @author Mike Churchward + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class feedback_settings_controller { + /** + * Constructor. + * + * @param questionnaire $questionnaire The active questionnaire. + */ + public function __construct( + /** @var questionnaire The active questionnaire. */ + private readonly questionnaire $questionnaire, + ) { + } + + /** + * Persist feedback settings from parsed form data. + * + * Writes the feedbacknotes draft area and applies an explicit allowlist of + * survey fields via {@see survey::update_settings()}. Throws if the + * underlying update rejects the changes. + * + * @param \stdClass $formdata Validated form data returned by feedback_form::get_data(). + * @return int Survey id on success. + */ + public function save(\stdClass $formdata): int { + $fields = [ + 'feedbackscores' => isset($formdata->feedbackscores) ? (int) $formdata->feedbackscores : 0, + 'feedbacknotes' => $this->save_feedbacknotes($formdata), + 'feedbacksections' => isset($formdata->feedbacksections) ? (int) $formdata->feedbacksections : 0, + ]; + + if ($fields['feedbacksections'] > 0 && get_config('questionnaire', 'usergraph')) { + $charttype = $this->extract_charttype($formdata, $fields['feedbacksections']); + if ($charttype !== null) { + $fields['charttype'] = $charttype; + } + } + + $result = $this->questionnaire->survey()->update_settings($fields); + if ($result === false) { + throw new \moodle_exception('couldnotcreatenewsurvey', 'mod_questionnaire'); + } + return $result; + } + + /** + * Ensure a first feedback section exists for the survey. + * + * Used by the "Save settings and edit Feedback Sections" path so the + * user always lands on a real section editor. Returns the id of the + * section to redirect to (existing first section, or newly created one). + * + * @return int Section id (0 if no sections exist and no feedback is configured). + */ + public function ensure_first_section(): int { + global $DB; + $surveyid = $this->questionnaire->surveyid(); + $firstsection = (int) ($DB->get_field( + 'questionnaire_fb_sections', + 'MIN(section)', + ['surveyid' => $surveyid] + ) ?: 0); + + $sections = $this->questionnaire->survey()->feedbacksections(); + if ($sections > 0 && $firstsection === 0) { + $label = ($sections === 1) + ? get_string('feedbackglobal', 'questionnaire') + : get_string('feedbackdefaultlabel', 'questionnaire'); + section::new_section($surveyid, $label); + } + return $firstsection; + } + + /** + * Save the feedbacknotes editor draft area and return the processed HTML body. + * + * Empty / missing editor data resolves to an empty string so the survey field + * is positively cleared instead of being left stale. + * + * @param \stdClass $formdata + * @return string + */ + private function save_feedbacknotes(\stdClass $formdata): string { + if (!isset($formdata->feedbacknotes) || !is_array($formdata->feedbacknotes)) { + return ''; + } + return file_save_draft_area_files( + (int) $formdata->feedbacknotes['itemid'], + $this->questionnaire->context()->id, + 'mod_questionnaire', + 'feedbacknotes', + $this->questionnaire->surveyid(), + ['subdirs' => true], + $formdata->feedbacknotes['text'] + ); + } + + /** + * Resolve which charttype the form picked given the number of sections. + * + * @param \stdClass $formdata + * @param int $sections + * @return string|null + */ + private function extract_charttype(\stdClass $formdata, int $sections): ?string { + return match (true) { + $sections === 1 => $formdata->chart_type_global ?? null, + $sections === 2 => $formdata->chart_type_two_sections ?? null, + $sections > 2 => $formdata->chart_type_sections ?? null, + default => null, + }; + } +} diff --git a/classes/form_options.php b/classes/form_options.php new file mode 100644 index 000000000..57f856088 --- /dev/null +++ b/classes/form_options.php @@ -0,0 +1,162 @@ +. + +namespace mod_questionnaire; + +use context; + +/** + * Static helpers that return option arrays and editor configs for questionnaire forms. + * + * Pure presentation helpers — no questionnaire instance state required. + * + * @package mod_questionnaire + * @copyright 2026 onward Mike Churchward (mike.churchward@poetopensource.org) + * @author Mike Churchward + * @license http://www.gnu.org/copyleft/gpl.html GNU Public License + */ +class form_options { + /** + * Return the JS module config used by Moodle's $PAGE->requires->js_init_call(). + * + * @return array + */ + public static function js_module(): array { + return [ + 'name' => 'mod_questionnaire', + 'fullpath' => '/mod/questionnaire/module.js', + 'requires' => ['base', 'dom', 'event-delegate', 'event-key', + 'core_question_engine', 'moodle-core-formchangechecker'], + 'strings' => [ + ['cancel', 'moodle'], + ['flagged', 'question'], + ['functiondisabledbysecuremode', 'quiz'], + ['startattempt', 'quiz'], + ['timesup', 'quiz'], + ['changesmadereallygoaway', 'moodle'], + ['leftpart', 'questionnaire'], + ['leftpartdefault', 'questionnaire'], + ['middlepart', 'questionnaire'], + ['middlepartdefault', 'questionnaire'], + ['middlepartwithtwovalues', 'questionnaire'], + ['middlepartwithtwovaluesdefault', 'questionnaire'], + ['rightpart', 'questionnaire'], + ['rightpartdefault', 'questionnaire'], + ['where', 'questionnaire'], + ], + ]; + } + + /** + * Return a localised value => label map of response-frequency options for form selects. + * + * @return array + */ + public static function response_frequency(): array { + return [ + questionnaire::QTYPE_UNLIMITED => get_string('qtypeunlimited', 'questionnaire'), + questionnaire::QTYPE_ONCE => get_string('qtypeonce', 'questionnaire'), + questionnaire::QTYPE_DAILY => get_string('qtypedaily', 'questionnaire'), + questionnaire::QTYPE_WEEKLY => get_string('qtypeweekly', 'questionnaire'), + questionnaire::QTYPE_MONTHLY => get_string('qtypemonthly', 'questionnaire'), + ]; + } + + /** + * Return a localised value => label map of student-response-viewer options for form selects. + * + * @return array + */ + public static function response_viewer(): array { + return [ + questionnaire::RESPVIEW_WHENANSWERED => get_string('responseviewstudentswhenanswered', 'questionnaire'), + questionnaire::RESPVIEW_WHENCLOSED => get_string('responseviewstudentswhenclosed', 'questionnaire'), + questionnaire::RESPVIEW_ALWAYS => get_string('responseviewstudentsalways', 'questionnaire'), + questionnaire::RESPVIEW_NEVER => get_string('responseviewstudentsnever', 'questionnaire'), + ]; + } + + /** + * Return a localised value => label map of respondent-type options for form selects. + * + * @return array + */ + public static function respondent_type(): array { + return [ + 'fullname' => get_string('respondenttypefullname', 'questionnaire'), + 'anonymous' => get_string('respondenttypeanonymous', 'questionnaire'), + ]; + } + + /** + * Return a localised value => label map of survey-realm options for form selects. + * + * @return array + */ + public static function realm(): array { + return [ + 'private' => get_string('private', 'questionnaire'), + 'public' => get_string('public', 'questionnaire'), + 'template' => get_string('template', 'questionnaire'), + ]; + } + + /** + * Return a localised value => label map of auto-numbering options for form selects. + * + * @return array + */ + public static function auto_numbering(): array { + return [ + 0 => get_string('autonumberno', 'questionnaire'), + 1 => get_string('autonumberquestions', 'questionnaire'), + 2 => get_string('autonumberpages', 'questionnaire'), + 3 => get_string('autonumberpagesandquestions', 'questionnaire'), + ]; + } + + /** + * Return the HTML-editor options array for editors used outside Moodle forms. + * + * @param context $context The module context. + * @return array + */ + public static function editor(context $context): array { + return [ + 'subdirs' => 0, + 'maxbytes' => 0, + 'maxfiles' => -1, + 'context' => $context, + 'noclean' => 0, + 'trusttext' => 0, + ]; + } + + /** + * Return a localised value => label map of response-removal duration options for admin form selects. + * + * @return array Keys are seconds (0 = never). + */ + public static function response_removal(): array { + $options = [0 => get_string('removeoldresponsesdefault', 'questionnaire')]; + for ($i = 1; $i <= 36; $i++) { + $options[$i * 2592000] = $i > 1 + ? get_string('nummonths', 'moodle', $i) + : get_string('onemonth', 'questionnaire'); + } + return $options; + } +} diff --git a/classes/generator/question_response.php b/classes/generator/question_response.php index fc4c7221c..be9d91bee 100644 --- a/classes/generator/question_response.php +++ b/classes/generator/question_response.php @@ -16,7 +16,7 @@ namespace mod_questionnaire\generator; -use mod_questionnaire\responsetype\response\response; +use mod_questionnaire\local\response\response; /** * Question response class diff --git a/classes/generator/question_response_rank.php b/classes/generator/question_response_rank.php index 4ff25a7e0..afe9ce332 100644 --- a/classes/generator/question_response_rank.php +++ b/classes/generator/question_response_rank.php @@ -16,7 +16,7 @@ namespace mod_questionnaire\generator; -use mod_questionnaire\question\choice; +use mod_questionnaire\local\question\choice; /** * Question response rank class diff --git a/classes/gradebook.php b/classes/gradebook.php new file mode 100644 index 000000000..7306615c4 --- /dev/null +++ b/classes/gradebook.php @@ -0,0 +1,171 @@ +. + +namespace mod_questionnaire; + +use stdClass; + +/** + * Moodle gradebook integration for the questionnaire module. + * + * Pure static glue between mod_questionnaire and core grade_update(). + * Accepts either a questionnaire domain object or a stdClass record from lib.php. + * + * @package mod_questionnaire + * @copyright 2026 onward Mike Churchward (mike.churchward@poetopensource.org) + * @author Mike Churchward + * @license http://www.gnu.org/copyleft/gpl.html GNU Public License + */ +class gradebook { + /** + * Return grade rows for the given questionnaire and (optional) user. + * + * @param object $questionnaire Either a questionnaire domain object or a stdClass record. + * @param int $userid Optional user id; 0 means all users. + * @return array + */ + public static function get_user_grades(object $questionnaire, int $userid = 0): array { + global $DB; + $qid = ($questionnaire instanceof questionnaire) ? $questionnaire->id() : $questionnaire->id; + $params = []; + $usersql = ''; + if (!empty($userid)) { + $usersql = "AND u.id = ?"; + $params[] = $userid; + } + + $sql = "SELECT r.id, u.id AS userid, r.grade AS rawgrade, r.submitted AS dategraded, r.submitted AS datesubmitted + FROM {user} u, {questionnaire_response} r + WHERE u.id = r.userid AND r.questionnaireid = $qid AND r.complete = 'y' $usersql"; + return $DB->get_records_sql($sql, $params) ?? []; + } + + /** + * Create or update the grade item for the given questionnaire. + * + * @param object $questionnaire Either a questionnaire domain object or a stdClass record (with cmidnumber). + * @param mixed $grades Optional array/object of grade(s); 'reset' means reset grades in gradebook. + * @return int 0 if ok, error code otherwise. + */ + public static function grade_item_update(object $questionnaire, mixed $grades = null): int { + global $CFG; + if (!function_exists('grade_update')) { + require_once($CFG->libdir . '/gradelib.php'); + } + + if ($questionnaire instanceof questionnaire) { + $qid = $questionnaire->id(); + $qname = $questionnaire->name(); + $qgrade = $questionnaire->grade(); + $qcourseid = $questionnaire->courseid(); + $qcmidnumber = $questionnaire->cmidnumber; + } else { + $qid = $questionnaire->instance ?? $questionnaire->id; + $qname = $questionnaire->name; + $qgrade = $questionnaire->grade; + $qcourseid = $questionnaire->courseid ?? $questionnaire->course; + $qcmidnumber = $questionnaire->cmidnumber; + } + + if ($qcmidnumber != '') { + $params = ['itemname' => $qname, 'idnumber' => $qcmidnumber]; + } else { + $params = ['itemname' => $qname]; + } + + if ($qgrade > 0) { + $params['gradetype'] = GRADE_TYPE_VALUE; + $params['grademax'] = $qgrade; + $params['grademin'] = 0; + } else if ($qgrade < 0) { + $params['gradetype'] = GRADE_TYPE_SCALE; + $params['scaleid'] = -$qgrade; + } else if ($qgrade == 0) { + $grades = null; + $params = ['deleted' => 1]; + } else { + $params = null; + } + + if ($grades === 'reset') { + $params['reset'] = true; + $grades = null; + } + + return grade_update( + 'mod/questionnaire', + $qcourseid, + 'mod', + 'questionnaire', + $qid, + 0, + $grades, + $params + ); + } + + /** + * Update grades by firing grade_updated event. + * + * @param object|null $questionnaire Questionnaire instance, or null to update all. + * @param int $userid Optional user id; 0 means all users. + * @param bool $nullifnone Unused; the Moodle API requires it. + * @return void + */ + public static function update_grades(?object $questionnaire = null, int $userid = 0, bool $nullifnone = true): void { + global $CFG, $DB; + + if (!function_exists('grade_update')) { + require_once($CFG->libdir . '/gradelib.php'); + } + + if ($questionnaire != null) { + if ($graderecs = self::get_user_grades($questionnaire, $userid)) { + $grades = []; + foreach ($graderecs as $v) { + if (!isset($grades[$v->userid])) { + $grades[$v->userid] = new stdClass(); + if ($v->rawgrade == -1) { + $grades[$v->userid]->rawgrade = null; + } else { + $grades[$v->userid]->rawgrade = $v->rawgrade; + } + $grades[$v->userid]->userid = $v->userid; + } else if (isset($grades[$v->userid]) && ($v->rawgrade > $grades[$v->userid]->rawgrade)) { + $grades[$v->userid]->rawgrade = $v->rawgrade; + } + } + self::grade_item_update($questionnaire, $grades); + } else { + self::grade_item_update($questionnaire); + } + } else { + $sql = "SELECT q.*, cm.idnumber as cmidnumber, q.course as courseid + FROM {questionnaire} q, {course_modules} cm, {modules} m + WHERE m.name='questionnaire' AND m.id=cm.module AND cm.instance=q.id"; + if ($rs = $DB->get_recordset_sql($sql)) { + foreach ($rs as $questionnaire) { + if ($questionnaire->grade != 0) { + self::update_grades($questionnaire); + } else { + self::grade_item_update($questionnaire); + } + } + $rs->close(); + } + } + } +} diff --git a/classes/instance_admin.php b/classes/instance_admin.php new file mode 100644 index 000000000..a648b8a2b --- /dev/null +++ b/classes/instance_admin.php @@ -0,0 +1,332 @@ +. + +namespace mod_questionnaire; + +use mod_questionnaire\local\db\dependency_record; +use mod_questionnaire\local\db\questionnaire_record; +use mod_questionnaire\local\db\survey_record; +use stdClass; + +/** + * Module instance lifecycle hooks for the questionnaire activity. + * + * Hosts the lib.php callbacks: create / update / delete a questionnaire instance, + * and reset user data on course reset. Also owns calendar event creation. + * + * @package mod_questionnaire + * @copyright 2026 onward Mike Churchward (mike.churchward@poetopensource.org) + * @author Mike Churchward + * @license http://www.gnu.org/copyleft/gpl.html GNU Public License + */ +class instance_admin { + /** @var int Maximum calendar event duration in seconds (5 days). */ + private const MAX_EVENT_LENGTH = 5 * 24 * 60 * 60; + + /** + * Create a new questionnaire activity instance. + * + * Resolves the survey (new blank / copy / existing public), inserts the questionnaire + * row, and fires calendar and completion events. Single point of delegation from + * lib.php's questionnaire_add_instance(). + * + * @param stdClass $formdata Form data from mod_form (coursemodule, course, name, …). + * @return int|false The new questionnaire instance id, or false on failure. + */ + public static function add_instance(stdClass $formdata): int|false { + if (!empty($formdata->sid)) { + // Survey already identified (e.g. restore or duplicate of a public survey). + $sid = $formdata->sid; + } else if ($formdata->create == 'new-0') { + // Brand-new blank survey. + $sdata = new stdClass(); + $sdata->name = $formdata->name; + $sdata->realm = 'private'; + $sdata->title = $formdata->name; + $sdata->subtitle = ''; + $sdata->info = ''; + $sdata->theme = ''; // Theme field is deprecated. + $sdata->thankspage = ''; + $sdata->thankhead = ''; + $sdata->thankbody = ''; + $sdata->email = ''; + $sdata->feedbacknotes = ''; + $sdata->courseid = $formdata->course; + $sid = survey::add_survey($sdata); + } else { + $parts = explode('-', $formdata->create); + $copyrealm = $parts[0]; + $copyid = (int) $parts[1]; + + if ($copyrealm == 'public') { + // Reuse the existing public survey — no copy needed. + $sid = $copyid; + } else { + // Copy the survey, its questions, choices, dependencies, and feedback. + $surveyrecord = (new survey_record($copyid))->to_record(); + $questions = survey::load_questions_for_survey($copyid); + $sid = survey::copy_survey($surveyrecord, $questions, $formdata->course); + + // All new questionnaires should be private, even copies of public/template surveys. + $copied = new survey_record($sid); + $copied->set('realm', 'private'); + $copied->update(); + + // Signal post-actions hook to copy file areas from the original. + $formdata->copyid = $copyid; + } + } + + // Enable navigation if the survey has dependency (skip-logic) data. + if (dependency_record::count_records(['surveyid' => $sid]) > 0) { + $formdata->navigate = 1; + } + $formdata->sid = $sid; + + $formdata->resume = ($formdata->resume == '1') ? 1 : 0; + + // Insert the questionnaire row. + $formdata->id = (questionnaire_record::create_from_formdata($formdata))->get('id'); + + self::set_events($formdata); + + $completiontimeexpected = !empty($formdata->completionexpected) ? $formdata->completionexpected : null; + \core_completion\api::update_completion_date_event( + $formdata->coursemodule, + 'questionnaire', + $formdata->id, + $completiontimeexpected + ); + + return $formdata->id; + } + + /** + * Update an existing questionnaire activity instance. + * + * Updates the survey realm when provided, updates the questionnaire row, and fires + * calendar and completion events. Single point of delegation from lib.php's + * questionnaire_update_instance(). + * + * @param stdClass $questionnaire Form data from mod_form (instance, sid, realm, …). + * @return bool True on success. + */ + public static function update_instance(stdClass $questionnaire): bool { + // Sync the survey realm when the form provides one. + if (!empty($questionnaire->sid) && !empty($questionnaire->realm)) { + $surveyrecord = new survey_record($questionnaire->sid); + $surveyrecord->set('realm', $questionnaire->realm); + $surveyrecord->update(); + } + + $questionnaire->id = $questionnaire->instance; + $questionnaire->resume = ($questionnaire->resume == '1') ? 1 : 0; + + // Update the questionnaire row (before_update() sets timemodified automatically). + $record = new questionnaire_record($questionnaire->id); + foreach ( + ['name', 'intro', 'introformat', 'qtype', 'respondenttype', + 'respeligible', 'respview', 'notifications', 'opendate', 'closedate', + 'resume', 'navigate', 'grade', 'sid', 'completionsubmit', + 'autonum', 'progressbar', 'removeafter'] as $f + ) { + if (isset($questionnaire->$f)) { + $record->set($f, $questionnaire->$f); + } + } + $record->update(); + + self::set_events($questionnaire); + + $completiontimeexpected = !empty($questionnaire->completionexpected) ? $questionnaire->completionexpected : null; + \core_completion\api::update_completion_date_event( + $questionnaire->coursemodule, + 'questionnaire', + $questionnaire->id, + $completiontimeexpected + ); + + return true; + } + + /** + * Delete a questionnaire instance and its survey data (if survey owned by this course). + * + * @param int $id Questionnaire instance id. + * @return bool True on success. + */ + public static function delete_instance(int $id): bool { + global $DB; + + if (!$questionnaire = $DB->get_record('questionnaire', ['id' => $id])) { + return false; + } + + $result = true; + + if ($events = $DB->get_records('event', ['modulename' => 'questionnaire', 'instance' => $questionnaire->id])) { + foreach ($events as $event) { + $event = \calendar_event::load($event); + $event->delete(); + } + } + + // Delete responses owned by this questionnaire instance, then the row itself. + self::delete_responses_for_instance($questionnaire->id); + if (!$DB->delete_records('questionnaire', ['id' => $questionnaire->id])) { + $result = false; + } + + // If the survey is owned by this course, delete the survey content too. + if ($surveyrow = $DB->get_record('questionnaire_survey', ['id' => $questionnaire->sid])) { + if ($surveyrow->courseid == $questionnaire->course) { + $result = $result && survey::from_sid((int) $questionnaire->sid)->delete(); + } + } + + return $result; + } + + /** + * Reset all user data for questionnaires in a course. + * + * @param object $data Data submitted from the reset course form. + * @return array Status array. + */ + public static function reset_userdata(object $data): array { + global $CFG, $DB; + require_once($CFG->libdir . '/questionlib.php'); + + $componentstr = get_string('modulenameplural', 'questionnaire'); + $status = []; + + if (!empty($data->reset_questionnaire)) { + $instances = $DB->get_records('questionnaire', ['course' => $data->courseid]); + foreach ($instances as $instance) { + $questionnaire = questionnaire::from_instanceid($instance->id); + // Delete responses. + $resps = $DB->get_records('questionnaire_response', ['questionnaireid' => $instance->id]); + foreach ($resps as $response) { + $questionnaire->responses()->delete_response($response); + } + // Remove this questionnaire's grades (and feedback) from gradebook (if any). + $select = "itemmodule = 'questionnaire' AND iteminstance = ?"; + if ($itemid = $DB->get_record_select('grade_items', $select, [$instance->id], 'id')) { + $DB->delete_records_select('grade_grades', 'itemid = ' . $itemid->id); + } + } + $status[] = [ + 'component' => $componentstr, + 'item' => get_string('deletedallresp', 'questionnaire'), + 'error' => false, + ]; + + $status[] = [ + 'component' => $componentstr, + 'item' => get_string('gradesdeleted', 'questionnaire'), + 'error' => false, + ]; + } + return $status; + } + + /** + * Create or update calendar events for a questionnaire instance. + * + * Deletes any existing calendar events for the instance, then creates open and/or + * close events based on the questionnaire's opendate and closedate. + * + * @param stdClass $questionnaire Questionnaire instance record (needs id, course, name, opendate, closedate). + * @return void + */ + public static function set_events(stdClass $questionnaire): void { + global $CFG, $DB; + require_once($CFG->dirroot . '/calendar/lib.php'); + + if ($events = $DB->get_records('event', ['modulename' => 'questionnaire', 'instance' => $questionnaire->id])) { + foreach ($events as $event) { + $event = \calendar_event::load($event); + $event->delete(); + } + } + + // The open-event. + $event = new stdClass(); + $event->description = $questionnaire->name; + $event->courseid = $questionnaire->course; + $event->groupid = 0; + $event->userid = 0; + $event->modulename = 'questionnaire'; + $event->instance = $questionnaire->id; + $event->eventtype = 'open'; + $event->type = CALENDAR_EVENT_TYPE_ACTION; + $event->timestart = $questionnaire->opendate; + $event->visible = instance_is_visible('questionnaire', $questionnaire); + $event->timeduration = ($questionnaire->closedate - $questionnaire->opendate); + + if ( + $questionnaire->closedate && $questionnaire->opendate + && ($event->timeduration <= self::MAX_EVENT_LENGTH) + ) { + // Single event for the whole questionnaire. + $event->name = $questionnaire->name; + $event->timesort = $questionnaire->opendate; + \calendar_event::create($event); + } else { + // Separate start and end events. + $event->timeduration = 0; + if ($questionnaire->opendate) { + $event->name = $questionnaire->name . + ' (' . get_string('questionnaireopens', 'questionnaire') . ')'; + $event->timesort = $questionnaire->opendate; + \calendar_event::create($event); + unset($event->id); // So we can use the same object for the close event. + } + if ($questionnaire->closedate) { + $event->name = $questionnaire->name . + ' (' . get_string('questionnairecloses', 'questionnaire') . ')'; + $event->timestart = $questionnaire->closedate; + $event->timesort = $questionnaire->closedate; + $event->eventtype = 'close'; + \calendar_event::create($event); + } + } + } + + /** + * Delete all response records (and their per-question rows) for a questionnaire instance. + * + * @param int $questionnaireid Questionnaire instance id. + * @return void + */ + private static function delete_responses_for_instance(int $questionnaireid): void { + global $DB; + + $rid = $DB->get_fieldset_select('questionnaire_response', 'id', 'questionnaireid = ?', [$questionnaireid]); + if (!empty($rid)) { + [$insql, $params] = $DB->get_in_or_equal($rid); + $DB->delete_records_select('questionnaire_response_bool', "responseid $insql", $params); + $DB->delete_records_select('questionnaire_response_date', "responseid $insql", $params); + $DB->delete_records_select('questionnaire_resp_multiple', "responseid $insql", $params); + $DB->delete_records_select('questionnaire_response_other', "responseid $insql", $params); + $DB->delete_records_select('questionnaire_response_rank', "responseid $insql", $params); + $DB->delete_records_select('questionnaire_resp_single', "responseid $insql", $params); + $DB->delete_records_select('questionnaire_response_text', "responseid $insql", $params); + $DB->delete_records_select('questionnaire_response_file', "responseid $insql", $params); + } + $DB->delete_records('questionnaire_response', ['questionnaireid' => $questionnaireid]); + } +} diff --git a/classes/local/db/choice_record.php b/classes/local/db/choice_record.php new file mode 100644 index 000000000..a4c0d039f --- /dev/null +++ b/classes/local/db/choice_record.php @@ -0,0 +1,74 @@ +. + +namespace mod_questionnaire\local\db; + +/** + * Persistent record for the questionnaire_quest_choice table. + * + * @package mod_questionnaire + * @copyright 2016 Mike Churchward (mike.churchward@poetopensource.org) + * @author Mike Churchward + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class choice_record extends \core\persistent { + /** @var string The table name. */ + public const TABLE = 'questionnaire_quest_choice'; + + /** + * Define the properties of this record. + * + * @return array + */ + protected static function define_properties(): array { + return [ + 'questionid' => [ + 'type' => PARAM_INT, + 'default' => 0, + ], + 'content' => [ + 'type' => PARAM_RAW, + 'default' => '', + ], + 'value' => [ + 'type' => PARAM_RAW, + 'null' => NULL_ALLOWED, + 'default' => null, + ], + ]; + } + + /** + * Return all choices for a given question. + * + * @param int $questionid + * @return choice_record[] + */ + public static function get_for_question(int $questionid): array { + return static::get_records(['questionid' => $questionid]); + } + + /** + * Bulk delete every choice row belonging to the given question. + * + * @param int $questionid + * @return bool + */ + public static function delete_for_question(int $questionid): bool { + global $DB; + return $DB->delete_records(static::TABLE, ['questionid' => $questionid]); + } +} diff --git a/classes/local/db/dependency_record.php b/classes/local/db/dependency_record.php new file mode 100644 index 000000000..d99238d20 --- /dev/null +++ b/classes/local/db/dependency_record.php @@ -0,0 +1,113 @@ +. + +namespace mod_questionnaire\local\db; + +/** + * Persistent record for the questionnaire_dependency table. + * + * Each row defines one condition that must be satisfied for a question to be shown + * (conditional branching / skip logic). Multiple rows for the same questionid are + * combined using the dependandor ('and'/'or') logic. + * + * @package mod_questionnaire + * @copyright 2016 Mike Churchward (mike.churchward@poetopensource.org) + * @author Mike Churchward + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class dependency_record extends \core\persistent { + /** @var string The table name. */ + public const TABLE = 'questionnaire_dependency'; + + /** + * Define the properties of this record. + * + * @return array + */ + protected static function define_properties(): array { + return [ + 'questionid' => [ + 'type' => PARAM_INT, + 'default' => 0, + ], + 'surveyid' => [ + 'type' => PARAM_INT, + 'default' => 0, + ], + 'dependquestionid' => [ + 'type' => PARAM_INT, + 'default' => 0, + ], + 'dependchoiceid' => [ + 'type' => PARAM_INT, + 'default' => 0, + ], + 'dependlogic' => [ + 'type' => PARAM_INT, + 'default' => 0, + ], + 'dependandor' => [ + 'type' => PARAM_ALPHA, + 'default' => '', + ], + ]; + } + + /** + * Return all dependencies for a given question. + * + * @param int $questionid + * @return dependency_record[] + */ + public static function get_for_question(int $questionid): array { + return static::get_records(['questionid' => $questionid]); + } + + /** + * Return all dependencies for all questions in a given survey. + * + * @param int $surveyid + * @return dependency_record[] + */ + public static function get_for_survey(int $surveyid): array { + return static::get_records(['surveyid' => $surveyid]); + } + + /** + * Bulk delete every dependency row belonging to the survey. + * + * @param int $surveyid + * @return bool + */ + public static function delete_for_survey(int $surveyid): bool { + global $DB; + return $DB->delete_records(static::TABLE, ['surveyid' => $surveyid]); + } + + /** + * Bulk delete every dependency that references the given question — both as the + * dependent question and as the question depended on. + * + * @param int $questionid + * @return bool + */ + public static function delete_for_question(int $questionid): bool { + global $DB; + $ok = $DB->delete_records(static::TABLE, ['questionid' => $questionid]); + $ok = $DB->delete_records(static::TABLE, ['dependquestionid' => $questionid]) && $ok; + return $ok; + } +} diff --git a/classes/local/db/feedback_record.php b/classes/local/db/feedback_record.php new file mode 100644 index 000000000..20de0e9c9 --- /dev/null +++ b/classes/local/db/feedback_record.php @@ -0,0 +1,95 @@ +. + +namespace mod_questionnaire\local\db; + +/** + * Persistent record for the questionnaire_feedback table. + * + * Each row defines a score band within a feedback section. When a respondent's + * score falls between minscore and maxscore, feedbacktext is shown. + * + * @package mod_questionnaire + * @copyright 2016 Mike Churchward (mike.churchward@poetopensource.org) + * @author Mike Churchward + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class feedback_record extends \core\persistent { + /** @var string The table name. */ + public const TABLE = 'questionnaire_feedback'; + + /** + * Define the properties of this record. + * + * @return array + */ + protected static function define_properties(): array { + return [ + 'sectionid' => [ + 'type' => PARAM_INT, + 'default' => 0, + ], + 'feedbacklabel' => [ + 'type' => PARAM_TEXT, + 'null' => NULL_ALLOWED, + 'default' => null, + ], + 'feedbacktext' => [ + 'type' => PARAM_RAW, + 'null' => NULL_ALLOWED, + 'default' => null, + ], + 'feedbacktextformat' => [ + 'type' => PARAM_INT, + 'null' => NULL_ALLOWED, + 'default' => 1, + ], + 'minscore' => [ + 'type' => PARAM_FLOAT, + 'null' => NULL_ALLOWED, + 'default' => 0, + ], + 'maxscore' => [ + 'type' => PARAM_FLOAT, + 'null' => NULL_ALLOWED, + 'default' => 101, + ], + ]; + } + + /** + * Return all feedback bands for a given section. + * + * @param int $sectionid + * @param string $sort Optional sort field name (e.g. 'minscore'). + * @param string $order Sort direction; ignored when $sort is empty. + * @return feedback_record[] + */ + public static function get_for_section(int $sectionid, string $sort = '', string $order = 'ASC'): array { + return static::get_records(['sectionid' => $sectionid], $sort, $order); + } + + /** + * Bulk delete every feedback-message row belonging to the given section. + * + * @param int $sectionid + * @return bool + */ + public static function delete_for_section(int $sectionid): bool { + global $DB; + return $DB->delete_records(static::TABLE, ['sectionid' => $sectionid]); + } +} diff --git a/classes/local/db/feedback_section_record.php b/classes/local/db/feedback_section_record.php new file mode 100644 index 000000000..ebc0a369a --- /dev/null +++ b/classes/local/db/feedback_section_record.php @@ -0,0 +1,108 @@ +. + +namespace mod_questionnaire\local\db; + +/** + * Persistent record for the questionnaire_fb_sections table. + * + * Defines a scored feedback section for a survey. Each section has a score + * calculation expression and optional heading content displayed to the respondent. + * + * @package mod_questionnaire + * @copyright 2016 Mike Churchward (mike.churchward@poetopensource.org) + * @author Mike Churchward + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class feedback_section_record extends \core\persistent { + /** @var string The table name. */ + public const TABLE = 'questionnaire_fb_sections'; + + /** + * Define the properties of this record. + * + * @return array + */ + protected static function define_properties(): array { + return [ + 'surveyid' => [ + 'type' => PARAM_INT, + 'default' => 0, + ], + 'section' => [ + 'type' => PARAM_INT, + 'null' => NULL_ALLOWED, + 'default' => null, + ], + 'scorecalculation' => [ + 'type' => PARAM_RAW, + 'null' => NULL_ALLOWED, + 'default' => null, + ], + 'sectionlabel' => [ + 'type' => PARAM_TEXT, + 'null' => NULL_ALLOWED, + 'default' => null, + ], + 'sectionheading' => [ + 'type' => PARAM_RAW, + 'null' => NULL_ALLOWED, + 'default' => null, + ], + 'sectionheadingformat' => [ + 'type' => PARAM_INT, + 'null' => NULL_ALLOWED, + 'default' => 1, + ], + ]; + } + + /** + * Return all feedback sections for a given survey. + * + * @param int $surveyid + * @param string $sort Optional sort field name (e.g. 'section'). + * @param string $order Sort direction; ignored when $sort is empty. + * @return feedback_section_record[] + */ + public static function get_for_survey(int $surveyid, string $sort = '', string $order = 'ASC'): array { + return static::get_records(['surveyid' => $surveyid], $sort, $order); + } + + /** + * Return the highest section number among feedback sections for the survey, + * or 0 if there are none. + * + * @param int $surveyid + * @return int + */ + public static function max_section_for_survey(int $surveyid): int { + global $DB; + $max = $DB->get_field(static::TABLE, 'MAX(section)', ['surveyid' => $surveyid]); + return (int) ($max ?: 0); + } + + /** + * Bulk delete every feedback-section row belonging to the survey. + * + * @param int $surveyid + * @return bool + */ + public static function delete_for_survey(int $surveyid): bool { + global $DB; + return $DB->delete_records(static::TABLE, ['surveyid' => $surveyid]); + } +} diff --git a/classes/local/db/question_record.php b/classes/local/db/question_record.php new file mode 100644 index 000000000..0a7d9b634 --- /dev/null +++ b/classes/local/db/question_record.php @@ -0,0 +1,312 @@ +. + +namespace mod_questionnaire\local\db; + +/** + * Persistent record for the questionnaire_question table. + * + * The column 'precise' holds the precision/max value for applicable types. + * + * @package mod_questionnaire + * @copyright 2016 Mike Churchward (mike.churchward@poetopensource.org) + * @author Mike Churchward + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class question_record extends \core\persistent { + /** @var string The table name. */ + public const TABLE = 'questionnaire_question'; + + /** + * Define the properties of this record. + * + * @return array + */ + protected static function define_properties(): array { + return [ + 'surveyid' => [ + 'type' => PARAM_INT, + 'default' => 0, + ], + 'name' => [ + 'type' => PARAM_TEXT, + 'null' => NULL_ALLOWED, + 'default' => null, + ], + 'typeid' => [ + 'type' => PARAM_INT, + 'default' => 0, + ], + 'resultid' => [ + 'type' => PARAM_INT, + 'null' => NULL_ALLOWED, + 'default' => null, + ], + 'length' => [ + 'type' => PARAM_INT, + 'default' => 0, + ], + 'precise' => [ + 'type' => PARAM_INT, + 'default' => 0, + ], + 'position' => [ + 'type' => PARAM_INT, + 'default' => 0, + ], + 'content' => [ + 'type' => PARAM_RAW, + 'default' => '', + ], + 'required' => [ + 'type' => PARAM_ALPHA, + 'default' => 'n', + ], + 'deleted' => [ + 'type' => PARAM_INT, + 'null' => NULL_ALLOWED, + 'default' => null, + ], + 'extradata' => [ + 'type' => PARAM_RAW, + 'null' => NULL_ALLOWED, + 'default' => null, + ], + ]; + } + + /** + * Return all non-deleted questions for a given survey, ordered by position. + * + * Uses a raw SQL query because Moodle's get_records() cannot express IS NULL conditions. + * + * @param int $surveyid + * @return question_record[] + */ + public static function get_active_for_survey(int $surveyid): array { + global $DB; + $records = $DB->get_records_select( + static::TABLE, + 'surveyid = :surveyid AND deleted IS NULL', + ['surveyid' => $surveyid], + 'position ASC' + ); + return array_map(fn($r) => new static(0, $r), $records); + } + + /** + * Return every question record for the given survey, including soft-deleted rows. + * + * @param int $surveyid + * @return question_record[] + */ + public static function get_for_survey(int $surveyid): array { + return static::get_records(['surveyid' => $surveyid]); + } + + /** + * Return question records of the given type, optionally restricted to a single survey. + * + * @param int $typeid + * @param int|null $surveyid Restrict to this survey, or null for all surveys. + * @return question_record[] + */ + public static function get_for_type(int $typeid, ?int $surveyid = null): array { + $filters = ['typeid' => $typeid]; + if ($surveyid !== null) { + $filters['surveyid'] = $surveyid; + } + return static::get_records($filters); + } + + /** + * Return every soft-deleted question record for the given survey, excluding page breaks, + * ordered by deletion time (most recent first). + * + * @param int $surveyid + * @return question_record[] + */ + public static function get_deleted_for_survey(int $surveyid): array { + global $DB; + $records = $DB->get_records_select( + static::TABLE, + 'deleted IS NOT NULL AND surveyid = :surveyid AND typeid != :pagebreak', + [ + 'surveyid' => $surveyid, + 'pagebreak' => \mod_questionnaire\local\question_type::QUESPAGEBREAK, + ], + 'deleted DESC' + ); + return array_map(fn($r) => new static(0, $r), $records); + } + + /** + * Return a soft-deleted question identified by id and survey, or null if none matches. + * + * @param int $questionid + * @param int $surveyid + * @return self|null + */ + public static function get_soft_deleted(int $questionid, int $surveyid): ?self { + global $DB; + $record = $DB->get_record_select( + static::TABLE, + 'id = :id AND surveyid = :surveyid AND deleted IS NOT NULL', + ['id' => $questionid, 'surveyid' => $surveyid] + ); + return $record ? new static(0, $record) : null; + } + + /** + * Return the highest position among active (non-deleted) questions in the survey, + * or 0 if there are none. + * + * @param int $surveyid + * @return int + */ + public static function max_active_position_for_survey(int $surveyid): int { + global $DB; + $max = $DB->get_field_select( + static::TABLE, + 'MAX(position)', + 'surveyid = :surveyid AND deleted IS NULL', + ['surveyid' => $surveyid] + ); + return (int) ($max ?: 0); + } + + /** + * Count active questions of a given type whose position is below the given value. + * + * @param int $surveyid + * @param int $typeid + * @param int $position + * @return int + */ + public static function count_active_before_position(int $surveyid, int $typeid, int $position): int { + global $DB; + return $DB->count_records_select( + static::TABLE, + 'surveyid = :surveyid AND typeid = :typeid AND position < :position AND deleted IS NULL', + ['surveyid' => $surveyid, 'typeid' => $typeid, 'position' => $position] + ); + } + + /** + * Update only the position field of a question row. + * + * @param int $questionid + * @param int $position + * @return bool + */ + public static function update_position(int $questionid, int $position): bool { + global $DB; + return $DB->set_field(static::TABLE, 'position', $position, ['id' => $questionid]); + } + + /** + * Mark a question as soft-deleted by stamping its deleted column with the current time. + * + * @param int $questionid + * @return bool + */ + public static function soft_delete(int $questionid): bool { + global $DB; + return $DB->set_field(static::TABLE, 'deleted', time(), ['id' => $questionid]); + } + + /** + * Return active questions in the survey whose position is greater than the given one, + * ordered by position ascending. + * + * @param int $surveyid + * @param int $position + * @return question_record[] + */ + public static function get_active_after_position(int $surveyid, int $position): array { + global $DB; + $records = $DB->get_records_select( + static::TABLE, + 'surveyid = :surveyid AND deleted IS NULL AND position > :pos', + ['surveyid' => $surveyid, 'pos' => $position], + 'position ASC' + ); + return array_map(fn($r) => new static(0, $r), $records); + } + + /** + * Insert a new page-break question row at the given position. Returns the created record. + * + * @param int $surveyid + * @param int $position + * @return self + */ + public static function create_pagebreak(int $surveyid, int $position): self { + $record = new static(0, (object)[ + 'surveyid' => $surveyid, + 'typeid' => \mod_questionnaire\local\question_type::QUESPAGEBREAK, + 'position' => $position, + 'content' => 'break', + ]); + $record->create(); + return $record; + } + + /** + * Bulk delete every question row belonging to the survey, regardless of deleted status. + * + * @param int $surveyid + * @return bool + */ + public static function delete_for_survey(int $surveyid): bool { + global $DB; + return $DB->delete_records(static::TABLE, ['surveyid' => $surveyid]); + } + + /** + * Permanently delete a soft-deleted question row identified by id and survey. + * + * No-op when the row is not currently soft-deleted, so callers can use this safely + * without first checking the deleted flag. + * + * @param int $questionid + * @param int $surveyid + * @return bool + */ + public static function delete_soft_deleted(int $questionid, int $surveyid): bool { + global $DB; + return $DB->delete_records_select( + static::TABLE, + 'id = :id AND surveyid = :surveyid AND deleted IS NOT NULL', + ['id' => $questionid, 'surveyid' => $surveyid] + ); + } + + /** + * Permanently delete every soft-deleted page-break question for the survey. + * + * @param int $surveyid + * @return bool + */ + public static function delete_soft_deleted_pagebreaks_for_survey(int $surveyid): bool { + global $DB; + return $DB->delete_records_select( + static::TABLE, + 'surveyid = :surveyid AND deleted IS NOT NULL AND typeid = :typeid', + ['surveyid' => $surveyid, 'typeid' => \mod_questionnaire\local\question_type::QUESPAGEBREAK] + ); + } +} diff --git a/classes/local/db/question_type_record.php b/classes/local/db/question_type_record.php new file mode 100644 index 000000000..13f3563ff --- /dev/null +++ b/classes/local/db/question_type_record.php @@ -0,0 +1,82 @@ +. + +namespace mod_questionnaire\local\db; + +/** + * Persistent record for the questionnaire_question_type table. + * + * This is a static lookup table populated at install time. The typeid column + * is the semantic identifier used throughout the plugin (not the auto-increment id). + * + * @package mod_questionnaire + * @copyright 2016 Mike Churchward (mike.churchward@poetopensource.org) + * @author Mike Churchward + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class question_type_record extends \core\persistent { + /** @var string The table name. */ + public const TABLE = 'questionnaire_question_type'; + + /** + * Define the properties of this record. + * + * @return array + */ + protected static function define_properties(): array { + return [ + 'typeid' => [ + 'type' => PARAM_INT, + 'default' => 0, + ], + 'type' => [ + 'type' => PARAM_TEXT, + 'default' => '', + ], + 'haschoices' => [ + 'type' => PARAM_ALPHA, + 'default' => 'y', + ], + 'responsetable' => [ + 'type' => PARAM_ALPHANUMEXT, + 'null' => NULL_ALLOWED, + 'default' => null, + ], + ]; + } + + /** + * Return the question type record for a given typeid. + * + * @param int $typeid + * @return question_type_record|false + */ + public static function get_by_typeid(int $typeid) { + $records = static::get_records(['typeid' => $typeid]); + return !empty($records) ? reset($records) : false; + } + + /** + * Return the question type record for a given typeid, or null if not found. + * + * @param int $typeid + * @return question_type_record|null + */ + public static function from_typeid(int $typeid): ?self { + $records = static::get_records(['typeid' => $typeid]); + return !empty($records) ? reset($records) : null; + } +} diff --git a/classes/local/db/questionnaire_record.php b/classes/local/db/questionnaire_record.php new file mode 100644 index 000000000..a34cd4038 --- /dev/null +++ b/classes/local/db/questionnaire_record.php @@ -0,0 +1,169 @@ +. + +namespace mod_questionnaire\local\db; + +/** + * Persistent record for the questionnaire table (the activity instance). + * + * @package mod_questionnaire + * @copyright 2016 Mike Churchward (mike.churchward@poetopensource.org) + * @author Mike Churchward + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class questionnaire_record extends \core\persistent { + /** @var string The table name. */ + public const TABLE = 'questionnaire'; + + /** + * Define the properties of this record. + * + * Property names map directly to DB column names. Types use Moodle PARAM_* constants. + * + * @return array + */ + protected static function define_properties(): array { + return [ + 'course' => [ + 'type' => PARAM_INT, + ], + 'name' => [ + 'type' => PARAM_TEXT, + ], + 'intro' => [ + 'type' => PARAM_RAW, + ], + 'introformat' => [ + 'type' => PARAM_INT, + 'default' => FORMAT_HTML, + ], + 'qtype' => [ + 'type' => PARAM_INT, + 'default' => 0, + ], + 'respondenttype' => [ + 'type' => PARAM_ALPHA, + 'default' => 'fullname', + ], + 'respeligible' => [ + 'type' => PARAM_ALPHA, + 'default' => 'all', + ], + 'respview' => [ + 'type' => PARAM_INT, + 'default' => 0, + ], + 'notifications' => [ + 'type' => PARAM_INT, + 'default' => 0, + ], + 'opendate' => [ + 'type' => PARAM_INT, + 'default' => 0, + ], + 'closedate' => [ + 'type' => PARAM_INT, + 'default' => 0, + ], + 'resume' => [ + 'type' => PARAM_INT, + 'default' => 0, + ], + 'navigate' => [ + 'type' => PARAM_INT, + 'default' => 0, + ], + 'grade' => [ + 'type' => PARAM_INT, + 'default' => 0, + ], + 'sid' => [ + 'type' => PARAM_INT, + 'default' => 0, + ], + 'timemodified' => [ + 'type' => PARAM_INT, + 'default' => 0, + ], + 'completionsubmit' => [ + 'type' => PARAM_INT, + 'default' => 0, + ], + 'autonum' => [ + 'type' => PARAM_INT, + 'default' => 3, + ], + 'progressbar' => [ + 'type' => PARAM_INT, + 'default' => 0, + ], + 'removeafter' => [ + 'type' => PARAM_INT, + 'default' => 0, + ], + ]; + } + + /** + * Return the first questionnaire instance linked to the given survey, or null if none exists. + * + * @param int $surveyid + * @return self|null + */ + public static function get_for_survey(int $surveyid): ?self { + $records = static::get_records(['sid' => $surveyid]); + return !empty($records) ? reset($records) : null; + } + + /** + * Return the questionnaire record linked to the given survey within the given course, or null. + * + * Used to find the "original" public questionnaire — the one whose course owns the survey, + * when the current questionnaire is a copy that points at the same shared survey. + * + * @param int $surveyid + * @param int $courseid + * @return self|null + */ + public static function get_for_survey_in_course(int $surveyid, int $courseid): ?self { + $records = static::get_records(['sid' => $surveyid, 'course' => $courseid]); + return !empty($records) ? reset($records) : null; + } + + /** + * Create a new questionnaire record from mod_form data. + * + * @param stdClass $formdata Form data from mod_form. + * @return self The created record. + */ + public static function create_from_formdata(\stdClass $formdata): self { + return (new self(0, $formdata))->create(); + } + + /** + * Set timemodified before creating a new record. + */ + protected function before_create(): void { + $this->raw_set('timemodified', time()); + } + + /** + * Update timemodified before saving changes. + */ + protected function before_update(): void { + $this->raw_set('timemodified', time()); + } +} diff --git a/classes/local/db/response_bool_record.php b/classes/local/db/response_bool_record.php new file mode 100644 index 000000000..436e6cd7b --- /dev/null +++ b/classes/local/db/response_bool_record.php @@ -0,0 +1,64 @@ +. + +namespace mod_questionnaire\local\db; + +/** + * Persistent record for the questionnaire_response_bool table. + * + * Stores yes/no answers. The choiceid column holds 'y' or 'n'. + * + * @package mod_questionnaire + * @copyright 2016 Mike Churchward (mike.churchward@poetopensource.org) + * @author Mike Churchward + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class response_bool_record extends \core\persistent { + /** @var string The table name. */ + public const TABLE = 'questionnaire_response_bool'; + + /** + * Define the properties of this record. + * + * @return array + */ + protected static function define_properties(): array { + return [ + 'responseid' => [ + 'type' => PARAM_INT, + 'default' => 0, + ], + 'questionid' => [ + 'type' => PARAM_INT, + 'default' => 0, + ], + 'choiceid' => [ + 'type' => PARAM_ALPHA, + 'default' => 'y', + ], + ]; + } + + /** + * Return all bool answers for a given response. + * + * @param int $responseid + * @return response_bool_record[] + */ + public static function get_for_response(int $responseid): array { + return static::get_records(['responseid' => $responseid]); + } +} diff --git a/classes/local/db/response_date_record.php b/classes/local/db/response_date_record.php new file mode 100644 index 000000000..4a46d8179 --- /dev/null +++ b/classes/local/db/response_date_record.php @@ -0,0 +1,65 @@ +. + +namespace mod_questionnaire\local\db; + +/** + * Persistent record for the questionnaire_response_date table. + * + * Stores date answers as text strings (e.g. 'yyyy/mm/dd'). + * + * @package mod_questionnaire + * @copyright 2016 Mike Churchward (mike.churchward@poetopensource.org) + * @author Mike Churchward + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class response_date_record extends \core\persistent { + /** @var string The table name. */ + public const TABLE = 'questionnaire_response_date'; + + /** + * Define the properties of this record. + * + * @return array + */ + protected static function define_properties(): array { + return [ + 'responseid' => [ + 'type' => PARAM_INT, + 'default' => 0, + ], + 'questionid' => [ + 'type' => PARAM_INT, + 'default' => 0, + ], + 'response' => [ + 'type' => PARAM_TEXT, + 'null' => NULL_ALLOWED, + 'default' => null, + ], + ]; + } + + /** + * Return all date answers for a given response. + * + * @param int $responseid + * @return response_date_record[] + */ + public static function get_for_response(int $responseid): array { + return static::get_records(['responseid' => $responseid]); + } +} diff --git a/classes/local/db/response_file_record.php b/classes/local/db/response_file_record.php new file mode 100644 index 000000000..00ecbee4c --- /dev/null +++ b/classes/local/db/response_file_record.php @@ -0,0 +1,65 @@ +. + +namespace mod_questionnaire\local\db; + +/** + * Persistent record for the questionnaire_response_file table. + * + * Links a response/question pair to an uploaded file stored in the Moodle + * files table. The fileid is a foreign key into {files}.id. + * + * @package mod_questionnaire + * @copyright 2016 Mike Churchward (mike.churchward@poetopensource.org) + * @author Mike Churchward + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class response_file_record extends \core\persistent { + /** @var string The table name. */ + public const TABLE = 'questionnaire_response_file'; + + /** + * Define the properties of this record. + * + * @return array + */ + protected static function define_properties(): array { + return [ + 'responseid' => [ + 'type' => PARAM_INT, + 'default' => 0, + ], + 'questionid' => [ + 'type' => PARAM_INT, + 'default' => 0, + ], + 'fileid' => [ + 'type' => PARAM_INT, + 'default' => 0, + ], + ]; + } + + /** + * Return all file answers for a given response. + * + * @param int $responseid + * @return response_file_record[] + */ + public static function get_for_response(int $responseid): array { + return static::get_records(['responseid' => $responseid]); + } +} diff --git a/classes/local/db/response_multiple_record.php b/classes/local/db/response_multiple_record.php new file mode 100644 index 000000000..a56890ad7 --- /dev/null +++ b/classes/local/db/response_multiple_record.php @@ -0,0 +1,65 @@ +. + +namespace mod_questionnaire\local\db; + +/** + * Persistent record for the questionnaire_resp_multiple table. + * + * Each row represents one selected choice in a multiple-choice (checkbox) question. + * Multiple rows per response/question are expected. + * + * @package mod_questionnaire + * @copyright 2016 Mike Churchward (mike.churchward@poetopensource.org) + * @author Mike Churchward + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class response_multiple_record extends \core\persistent { + /** @var string The table name. */ + public const TABLE = 'questionnaire_resp_multiple'; + + /** + * Define the properties of this record. + * + * @return array + */ + protected static function define_properties(): array { + return [ + 'responseid' => [ + 'type' => PARAM_INT, + 'default' => 0, + ], + 'questionid' => [ + 'type' => PARAM_INT, + 'default' => 0, + ], + 'choiceid' => [ + 'type' => PARAM_INT, + 'default' => 0, + ], + ]; + } + + /** + * Return all multiple-choice answers for a given response. + * + * @param int $responseid + * @return response_multiple_record[] + */ + public static function get_for_response(int $responseid): array { + return static::get_records(['responseid' => $responseid]); + } +} diff --git a/classes/local/db/response_other_record.php b/classes/local/db/response_other_record.php new file mode 100644 index 000000000..ee6c69fb0 --- /dev/null +++ b/classes/local/db/response_other_record.php @@ -0,0 +1,69 @@ +. + +namespace mod_questionnaire\local\db; + +/** + * Persistent record for the questionnaire_response_other table. + * + * Stores the free-text value entered in an "Other..." option of a choice question. + * + * @package mod_questionnaire + * @copyright 2016 Mike Churchward (mike.churchward@poetopensource.org) + * @author Mike Churchward + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class response_other_record extends \core\persistent { + /** @var string The table name. */ + public const TABLE = 'questionnaire_response_other'; + + /** + * Define the properties of this record. + * + * @return array + */ + protected static function define_properties(): array { + return [ + 'responseid' => [ + 'type' => PARAM_INT, + 'default' => 0, + ], + 'questionid' => [ + 'type' => PARAM_INT, + 'default' => 0, + ], + 'choiceid' => [ + 'type' => PARAM_INT, + 'default' => 0, + ], + 'response' => [ + 'type' => PARAM_TEXT, + 'null' => NULL_ALLOWED, + 'default' => null, + ], + ]; + } + + /** + * Return all "other" answers for a given response. + * + * @param int $responseid + * @return response_other_record[] + */ + public static function get_for_response(int $responseid): array { + return static::get_records(['responseid' => $responseid]); + } +} diff --git a/classes/local/db/response_rank_record.php b/classes/local/db/response_rank_record.php new file mode 100644 index 000000000..0b99e443d --- /dev/null +++ b/classes/local/db/response_rank_record.php @@ -0,0 +1,100 @@ +. + +namespace mod_questionnaire\local\db; + +/** + * Persistent record for the questionnaire_response_rank table. + * + * Each row holds the rank value assigned to one choice in a rate/rank question. + * + * @package mod_questionnaire + * @copyright 2016 Mike Churchward (mike.churchward@poetopensource.org) + * @author Mike Churchward + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class response_rank_record extends \core\persistent { + /** @var string The table name. */ + public const TABLE = 'questionnaire_response_rank'; + + /** + * Define the properties of this record. + * + * @return array + */ + protected static function define_properties(): array { + return [ + 'responseid' => [ + 'type' => PARAM_INT, + 'default' => 0, + ], + 'questionid' => [ + 'type' => PARAM_INT, + 'default' => 0, + ], + 'choiceid' => [ + 'type' => PARAM_INT, + 'default' => 0, + ], + 'rankvalue' => [ + 'type' => PARAM_INT, + 'default' => 0, + ], + ]; + } + + /** + * Return all rank answers for a given response. + * + * @param int $responseid + * @return response_rank_record[] + */ + public static function get_for_response(int $responseid): array { + return static::get_records(['responseid' => $responseid]); + } + + /** + * Return all rank answers for a given question. + * + * @param int $questionid + * @return response_rank_record[] + */ + public static function get_for_question(int $questionid): array { + return static::get_records(['questionid' => $questionid]); + } + + /** + * Bulk-increment every non-negative rankvalue by 1, optionally restricted to a set of questions. + * + * Used by the one-time upgrade routine that converted zero-based rank values to one-based. + * + * @param int[]|null $questionids Restrict to these question ids; null applies to all questions. + * @return bool + */ + public static function increment_rankvalues(?array $questionids = null): bool { + global $DB; + $sql = 'UPDATE {' . static::TABLE . '} SET rankvalue = (rankvalue + 1) WHERE rankvalue >= 0'; + $params = []; + if ($questionids !== null) { + if (empty($questionids)) { + return true; + } + [$insql, $params] = $DB->get_in_or_equal($questionids); + $sql .= ' AND questionid ' . $insql; + } + return $DB->execute($sql, $params); + } +} diff --git a/classes/local/db/response_record.php b/classes/local/db/response_record.php new file mode 100644 index 000000000..8901902c7 --- /dev/null +++ b/classes/local/db/response_record.php @@ -0,0 +1,254 @@ +. + +namespace mod_questionnaire\local\db; + +/** + * Persistent record for the questionnaire_response table. + * + * Each row represents a single attempt (complete or in-progress) by a user. + * The 'complete' field uses 'y'/'n' rather than a boolean integer. + * + * @package mod_questionnaire + * @copyright 2016 Mike Churchward (mike.churchward@poetopensource.org) + * @author Mike Churchward + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class response_record extends \core\persistent { + /** @var string The table name. */ + public const TABLE = 'questionnaire_response'; + + /** + * Define the properties of this record. + * + * @return array + */ + protected static function define_properties(): array { + return [ + 'questionnaireid' => [ + 'type' => PARAM_INT, + 'default' => 0, + ], + 'submitted' => [ + 'type' => PARAM_INT, + 'default' => 0, + ], + 'complete' => [ + 'type' => PARAM_ALPHA, + 'default' => 'n', + ], + 'grade' => [ + 'type' => PARAM_INT, + 'default' => 0, + ], + 'userid' => [ + 'type' => PARAM_INT, + 'null' => NULL_ALLOWED, + 'default' => null, + ], + ]; + } + + /** + * Return all responses for a given questionnaire instance. + * + * @param int $questionnaireid + * @return response_record[] + */ + public static function get_for_questionnaire(int $questionnaireid): array { + return static::get_records(['questionnaireid' => $questionnaireid]); + } + + /** + * Return the response with the given id, or null if it does not exist. + * + * Persistent's constructor throws on a missing id; callers that want to + * branch on "exists or not" should use this finder instead. + * + * @param int $rid + * @return response_record|null + */ + public static function get_or_null(int $rid): ?self { + if (empty($rid)) { + return null; + } + return static::record_exists($rid) ? new self($rid) : null; + } + + /** + * Return all complete responses for a given questionnaire and user. + * + * @param int $questionnaireid + * @param int $userid + * @return response_record[] + */ + public static function get_complete_for_user(int $questionnaireid, int $userid): array { + return static::get_records(['questionnaireid' => $questionnaireid, 'userid' => $userid, 'complete' => 'y']); + } + + /** + * True if the given user has at least one complete response for the given questionnaire. + * + * @param int $questionnaireid + * @param int $userid + * @return bool + */ + public static function user_has_complete_response(int $questionnaireid, int $userid): bool { + return static::record_exists_select( + 'questionnaireid = :questionnaireid AND userid = :userid AND complete = :complete', + ['questionnaireid' => $questionnaireid, 'userid' => $userid, 'complete' => 'y'] + ); + } + + /** + * True if the given user has an in-progress (incomplete) saved response for the given questionnaire. + * + * @param int $questionnaireid + * @param int $userid + * @return bool + */ + public static function user_has_saved_response(int $questionnaireid, int $userid): bool { + return static::record_exists_select( + 'questionnaireid = :questionnaireid AND userid = :userid AND complete = :complete', + ['questionnaireid' => $questionnaireid, 'userid' => $userid, 'complete' => 'n'] + ); + } + + /** + * Count complete responses for one questionnaire instance. + * + * @param int $questionnaireid Restrict to this instance. + * @param int|false $userid Restrict to a specific user, or false for all users. + * @param int $groupid Restrict to members of this group, or 0 for no group filter. + * @return int + */ + public static function count_complete_for_questionnaire( + int $questionnaireid, + $userid = false, + int $groupid = 0 + ): int { + global $DB; + + $params = ['questionnaireid' => $questionnaireid, 'status' => 'y']; + $sql = 'SELECT COUNT(r.id) + FROM {questionnaire_response} r '; + if ($groupid != 0) { + $sql .= 'INNER JOIN {groups_members} gm ON r.userid = gm.userid '; + } + $sql .= 'WHERE r.questionnaireid = :questionnaireid AND r.complete = :status'; + if ($groupid != 0) { + $sql .= ' AND gm.groupid = :groupid'; + $params['groupid'] = $groupid; + } + if ($userid) { + $sql .= ' AND r.userid = :userid'; + $params['userid'] = $userid; + } + return $DB->count_records_sql($sql, $params); + } + + /** + * Count complete responses across every questionnaire instance that points at the given (public) survey. + * + * @param int $surveyid The public-master survey id. + * @param int|false $userid Restrict to a specific user, or false for all users. + * @param int $groupid Restrict to members of this group, or 0 for no group filter. + * @return int + */ + public static function count_complete_for_public_survey( + int $surveyid, + $userid = false, + int $groupid = 0 + ): int { + global $DB; + + $params = ['surveyid' => $surveyid, 'status' => 'y']; + $sql = 'SELECT COUNT(r.id) + FROM {questionnaire_response} r + INNER JOIN {questionnaire} q ON r.questionnaireid = q.id + INNER JOIN {questionnaire_survey} s ON q.sid = s.id '; + if ($groupid != 0) { + $sql .= 'INNER JOIN {groups_members} gm ON r.userid = gm.userid '; + } + $sql .= 'WHERE s.id = :surveyid AND r.complete = :status'; + if ($groupid != 0) { + $sql .= ' AND gm.groupid = :groupid'; + $params['groupid'] = $groupid; + } + if ($userid) { + $sql .= ' AND r.userid = :userid'; + $params['userid'] = $userid; + } + return $DB->count_records_sql($sql, $params); + } + + /** + * Return the course fullname for the questionnaire that owns the given complete response, or null. + * + * Used when displaying respondent info for a response submitted against a public survey, + * where the response may belong to a questionnaire instance in a different course. + * + * @param int $rid Response id. + * @return string|null + */ + public static function get_course_fullname(int $rid): ?string { + global $DB; + + $sql = 'SELECT c.fullname + FROM {questionnaire_response} r + INNER JOIN {questionnaire} q ON r.questionnaireid = q.id + INNER JOIN {course} c ON q.course = c.id + WHERE r.id = :rid AND r.complete = :status'; + $fullname = $DB->get_field_sql($sql, ['rid' => $rid, 'status' => 'y']); + return $fullname === false ? null : $fullname; + } + + /** + * Return the most recent incomplete response record for the given questionnaire and user, or null. + * + * @param int $questionnaireid + * @param int $userid + * @return response_record|null + */ + public static function get_latest_incomplete(int $questionnaireid, int $userid): ?self { + $records = static::get_records( + ['questionnaireid' => $questionnaireid, 'userid' => $userid, 'complete' => 'n'], + 'submitted', + 'DESC', + 0, + 1 + ); + return empty($records) ? null : reset($records); + } + + /** + * Return the most recent complete response record for the given questionnaire and user, or null. + * + * @param int $questionnaireid + * @param int $userid + * @return response_record|null + */ + public static function get_latest_complete_for_user(int $questionnaireid, int $userid): ?self { + $records = static::get_records( + ['questionnaireid' => $questionnaireid, 'userid' => $userid, 'complete' => 'y'], + 'submitted', + 'DESC', + 0, + 1 + ); + return empty($records) ? null : reset($records); + } +} diff --git a/classes/local/db/response_single_record.php b/classes/local/db/response_single_record.php new file mode 100644 index 000000000..361b8ae29 --- /dev/null +++ b/classes/local/db/response_single_record.php @@ -0,0 +1,64 @@ +. + +namespace mod_questionnaire\local\db; + +/** + * Persistent record for the questionnaire_resp_single table. + * + * Stores the single selected choice for a radio-button question. + * + * @package mod_questionnaire + * @copyright 2016 Mike Churchward (mike.churchward@poetopensource.org) + * @author Mike Churchward + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class response_single_record extends \core\persistent { + /** @var string The table name. */ + public const TABLE = 'questionnaire_resp_single'; + + /** + * Define the properties of this record. + * + * @return array + */ + protected static function define_properties(): array { + return [ + 'responseid' => [ + 'type' => PARAM_INT, + 'default' => 0, + ], + 'questionid' => [ + 'type' => PARAM_INT, + 'default' => 0, + ], + 'choiceid' => [ + 'type' => PARAM_INT, + 'default' => 0, + ], + ]; + } + + /** + * Return all single-choice answers for a given response. + * + * @param int $responseid + * @return response_single_record[] + */ + public static function get_for_response(int $responseid): array { + return static::get_records(['responseid' => $responseid]); + } +} diff --git a/classes/local/db/response_text_record.php b/classes/local/db/response_text_record.php new file mode 100644 index 000000000..e48d86071 --- /dev/null +++ b/classes/local/db/response_text_record.php @@ -0,0 +1,66 @@ +. + +namespace mod_questionnaire\local\db; + +/** + * Persistent record for the questionnaire_response_text table. + * + * Stores free-text answers. PARAM_RAW is used because responses may contain + * formatted content (HTML, equations, etc.) entered by the respondent. + * + * @package mod_questionnaire + * @copyright 2016 Mike Churchward (mike.churchward@poetopensource.org) + * @author Mike Churchward + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class response_text_record extends \core\persistent { + /** @var string The table name. */ + public const TABLE = 'questionnaire_response_text'; + + /** + * Define the properties of this record. + * + * @return array + */ + protected static function define_properties(): array { + return [ + 'responseid' => [ + 'type' => PARAM_INT, + 'default' => 0, + ], + 'questionid' => [ + 'type' => PARAM_INT, + 'default' => 0, + ], + 'response' => [ + 'type' => PARAM_RAW, + 'null' => NULL_ALLOWED, + 'default' => null, + ], + ]; + } + + /** + * Return all text answers for a given response. + * + * @param int $responseid + * @return response_text_record[] + */ + public static function get_for_response(int $responseid): array { + return static::get_records(['responseid' => $responseid]); + } +} diff --git a/classes/local/db/survey_record.php b/classes/local/db/survey_record.php new file mode 100644 index 000000000..7c63c9a4a --- /dev/null +++ b/classes/local/db/survey_record.php @@ -0,0 +1,177 @@ +. + +namespace mod_questionnaire\local\db; + +/** + * Persistent record for the questionnaire_survey table. + * + * The survey is the content container for a questionnaire. A survey can be + * shared across multiple questionnaire activity instances (public/template surveys). + * + * @package mod_questionnaire + * @copyright 2016 Mike Churchward (mike.churchward@poetopensource.org) + * @author Mike Churchward + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class survey_record extends \core\persistent { + /** @var string The table name. */ + public const TABLE = 'questionnaire_survey'; + + /** + * Define the properties of this record. + * + * @return array + */ + protected static function define_properties(): array { + return [ + 'name' => [ + 'type' => PARAM_TEXT, + ], + 'courseid' => [ + 'type' => PARAM_INT, + 'null' => NULL_ALLOWED, + 'default' => null, + ], + 'realm' => [ + 'type' => PARAM_ALPHA, + 'default' => '', + ], + 'status' => [ + 'type' => PARAM_INT, + 'default' => 0, + ], + 'title' => [ + 'type' => PARAM_TEXT, + 'default' => '', + ], + 'email' => [ + 'type' => PARAM_TEXT, + 'null' => NULL_ALLOWED, + 'default' => null, + ], + 'subtitle' => [ + 'type' => PARAM_RAW, + 'null' => NULL_ALLOWED, + 'default' => null, + ], + 'info' => [ + 'type' => PARAM_RAW, + 'null' => NULL_ALLOWED, + 'default' => null, + ], + 'theme' => [ + 'type' => PARAM_TEXT, + 'null' => NULL_ALLOWED, + 'default' => null, + ], + 'thankspage' => [ + 'type' => PARAM_URL, + 'null' => NULL_ALLOWED, + 'default' => null, + ], + 'thankhead' => [ + 'type' => PARAM_TEXT, + 'null' => NULL_ALLOWED, + 'default' => null, + ], + 'thankbody' => [ + 'type' => PARAM_RAW, + 'null' => NULL_ALLOWED, + 'default' => null, + ], + 'feedbacksections' => [ + 'type' => PARAM_INT, + 'null' => NULL_ALLOWED, + 'default' => 0, + ], + 'feedbacknotes' => [ + 'type' => PARAM_RAW, + 'null' => NULL_ALLOWED, + 'default' => null, + ], + 'feedbackscores' => [ + 'type' => PARAM_INT, + 'null' => NULL_ALLOWED, + 'default' => 0, + ], + 'charttype' => [ + 'type' => PARAM_ALPHA, + 'null' => NULL_ALLOWED, + 'default' => null, + ], + ]; + } + + /** + * Return true if a survey record with the given id exists in the database. + * + * @param int $id + * @return bool + */ + public static function record_exists($id): bool { + return static::record_exists_select('id = ?', [$id]); + } + + /** + * Return all surveys with the given realm belonging to a specific course. + * + * @param string $realm + * @param int $courseid + * @return self[] + */ + public static function get_by_realm_in_course(string $realm, int $courseid): array { + return static::get_records(['realm' => $realm, 'courseid' => $courseid], 'name'); + } + + /** + * Return all surveys with the given realm across all courses. + * + * @param string $realm + * @return self[] + */ + public static function get_by_realm(string $realm): array { + return static::get_records(['realm' => $realm], 'name'); + } + + /** + * Return every survey that has no linked questionnaire instance row. + * + * Used by the cleanup task to garbage-collect surveys whose questionnaire + * was deleted without taking the survey with it (because the survey was + * shared at the time, or because of a partial-delete bug in older versions). + * + * @return self[] + */ + public static function get_orphaned(): array { + global $DB; + $sql = 'SELECT qs.* FROM {' . static::TABLE . '} qs + LEFT JOIN {questionnaire} q ON q.sid = qs.id + WHERE q.sid IS NULL'; + $records = $DB->get_records_sql($sql); + return array_map(fn($r) => new static(0, $r), $records); + } + + /** + * Create a new survey record from survey data. + * + * @param stdClass $sdata Survey data object. + * @return self The created record. + */ + public static function create_from_sdata(\stdClass $sdata): self { + return (new self(0, $sdata))->create(); + } +} diff --git a/classes/feedback/section.php b/classes/local/feedback/section.php similarity index 76% rename from classes/feedback/section.php rename to classes/local/feedback/section.php index df05c2824..52cb354c8 100644 --- a/classes/feedback/section.php +++ b/classes/local/feedback/section.php @@ -14,14 +14,13 @@ // You should have received a copy of the GNU General Public License // along with Moodle. If not, see . -namespace mod_questionnaire\feedback; - -defined('MOODLE_INTERNAL') || die(); +namespace mod_questionnaire\local\feedback; +use mod_questionnaire\local\db\feedback_record; +use mod_questionnaire\local\db\feedback_section_record; use invalid_parameter_exception; use coding_exception; -#[\AllowDynamicProperties] /** * Class for describing a feedback section. * @@ -49,6 +48,10 @@ class section { public $sectionfeedback = []; /** @var array */ public $questions = []; + /** @var int Survey id set externally (e.g. in fbsections.php form data). */ + public $sid = 0; + /** @var int Section id set externally (e.g. in fbsections.php form data). */ + public $sectionid = 0; /** The table name. */ const TABLE = 'questionnaire_fb_sections'; @@ -89,19 +92,26 @@ public function __construct($questions, $params = []) { * @return section */ public static function new_section($surveyid, $sectionlabel = '') { - global $DB; - $newsection = new self([], []); if (empty($sectionlabel)) { $sectionlabel = get_string('feedbackdefaultlabel', 'questionnaire'); } - $maxsection = $DB->get_field(self::TABLE, 'MAX(section)', ['surveyid' => $surveyid]); $newsection->surveyid = $surveyid; - $newsection->section = $maxsection + 1; + $newsection->section = feedback_section_record::max_section_for_survey($surveyid) + 1; $newsection->sectionlabel = $sectionlabel; $newsection->scorecalculation = $newsection->encode_scorecalculation([]); - $newsecid = $DB->insert_record(self::TABLE, $newsection); - $newsection->id = $newsecid; + + $record = new feedback_section_record(0, (object)[ + 'surveyid' => $newsection->surveyid, + 'section' => $newsection->section, + 'sectionlabel' => $newsection->sectionlabel, + 'sectionheading' => $newsection->sectionheading, + 'sectionheadingformat' => $newsection->sectionheadingformat, + 'scorecalculation' => $newsection->scorecalculation, + ]); + $record->create(); + + $newsection->id = $record->get('id'); $newsection->scorecalculation = []; return $newsection; } @@ -118,42 +128,33 @@ public static function new_section($surveyid, $sectionlabel = '') { * @throws invalid_parameter_exception */ public function load_section($params) { - global $DB; - if (!is_array($params)) { throw new coding_exception('Invalid data provided.'); } else if (isset($params['id'])) { - $where = 'WHERE fs.id = :id '; + $sectionrec = feedback_section_record::get_record(['id' => $params['id']]); } else if (isset($params['surveyid'])) { - $where = 'WHERE fs.surveyid = :surveyid AND fs.section = :sectionnum '; - if (!isset($params['sectionnum'])) { - $params['sectionnum'] = 1; - } + $sectionrec = feedback_section_record::get_record([ + 'surveyid' => $params['surveyid'], + 'section' => $params['sectionnum'] ?? 1, + ]); } else { throw new coding_exception('No valid data parameters provided.'); } - $select = 'SELECT f.id as fbid, fs.*, f.feedbacklabel, f.feedbacktext, f.feedbacktextformat, f.minscore, f.maxscore '; - $from = 'FROM {' . self::TABLE . '} fs LEFT JOIN {' . sectionfeedback::TABLE . '} f ON fs.id = f.sectionid '; - $order = 'ORDER BY minscore DESC'; - - if (!($feedbackrecs = $DB->get_records_sql($select . $from . $where . $order, $params))) { + if (!$sectionrec) { throw new invalid_parameter_exception('No feedback sections exists for that data.'); } - foreach ($feedbackrecs as $fbid => $feedbackrec) { - if (empty($this->id)) { - $this->id = $feedbackrec->id; - $this->surveyid = $feedbackrec->surveyid; - $this->section = $feedbackrec->section; - $this->scorecalculation = $this->get_valid_scorecalculation($feedbackrec->scorecalculation); - $this->sectionlabel = $feedbackrec->sectionlabel; - $this->sectionheading = $feedbackrec->sectionheading; - $this->sectionheadingformat = $feedbackrec->sectionheadingformat; - } - if (!empty($fbid)) { - $feedbackrec->id = $fbid; - $this->sectionfeedback[$fbid] = new sectionfeedback(0, $feedbackrec); - } + + $this->id = $sectionrec->get('id'); + $this->surveyid = $sectionrec->get('surveyid'); + $this->section = $sectionrec->get('section'); + $this->scorecalculation = $this->get_valid_scorecalculation($sectionrec->get('scorecalculation')); + $this->sectionlabel = $sectionrec->get('sectionlabel'); + $this->sectionheading = $sectionrec->get('sectionheading'); + $this->sectionheadingformat = $sectionrec->get('sectionheadingformat'); + + foreach (feedback_record::get_for_section($this->id, 'minscore', 'DESC') as $feedbackrec) { + $this->sectionfeedback[$feedbackrec->get('id')] = new sectionfeedback(0, $feedbackrec->to_record()); } } @@ -181,15 +182,15 @@ public function load_sectionfeedback($feedbackrec) { * @throws coding_exception */ public function set_new_scorecalculation($scorecalculation = null) { - global $DB; - if ($scorecalculation == null) { $scorecalculation = $this->scorecalculation; } if (is_array($scorecalculation)) { $newscore = $this->encode_scorecalculation($scorecalculation); - $DB->set_field(self::TABLE, 'scorecalculation', $newscore, ['id' => $this->id]); + $record = new feedback_section_record($this->id); + $record->set('scorecalculation', $newscore); + $record->update(); $this->scorecalculation = $scorecalculation; } else { throw new coding_exception('Invalid scorecalculation format.'); @@ -215,20 +216,19 @@ public function remove_question($qid) { * This will also adjust the section numbers so that they are sequential and begin at 1. */ public function delete() { - global $DB; - $this->delete_sectionfeedback(); - $DB->delete_records(self::TABLE, ['id' => $this->id]); + + $record = new feedback_section_record($this->id); + $record->delete(); // Resequence the section numbers as necessary. - if ($allsections = $DB->get_records(self::TABLE, ['surveyid' => $this->surveyid], 'section ASC')) { - $count = 1; - foreach ($allsections as $id => $section) { - if ($section->section != $count) { - $DB->set_field(self::TABLE, 'section', $count, ['id' => $id]); - } - $count++; + $count = 1; + foreach (feedback_section_record::get_for_survey($this->surveyid, 'section') as $section) { + if ($section->get('section') != $count) { + $section->set('section', $count); + $section->update(); } + $count++; } } @@ -238,10 +238,8 @@ public function delete() { * @throws \dml_exception */ public function delete_sectionfeedback() { - global $DB; - // It's quicker to delete all of the records at once then to go through the array and delete each object. - $DB->delete_records(sectionfeedback::TABLE, ['sectionid' => $this->id]); + feedback_record::delete_for_section($this->id); $this->sectionfeedback = []; } @@ -252,10 +250,18 @@ public function delete_sectionfeedback() { * @throws coding_exception */ public function update() { - global $DB; - $this->scorecalculation = $this->encode_scorecalculation($this->scorecalculation); - $DB->update_record(self::TABLE, $this); + + $record = new feedback_section_record($this->id, (object)[ + 'surveyid' => $this->surveyid, + 'section' => $this->section, + 'sectionlabel' => $this->sectionlabel, + 'sectionheading' => $this->sectionheading, + 'sectionheadingformat' => $this->sectionheadingformat, + 'scorecalculation' => $this->scorecalculation, + ]); + $record->update(); + $this->scorecalculation = $this->get_valid_scorecalculation($this->scorecalculation); foreach ($this->sectionfeedback as $sectionfeedback) { diff --git a/classes/feedback/sectionfeedback.php b/classes/local/feedback/sectionfeedback.php similarity index 74% rename from classes/feedback/sectionfeedback.php rename to classes/local/feedback/sectionfeedback.php index 69e562913..6288d7485 100644 --- a/classes/feedback/sectionfeedback.php +++ b/classes/local/feedback/sectionfeedback.php @@ -14,8 +14,9 @@ // You should have received a copy of the GNU General Public License // along with Moodle. If not, see . -namespace mod_questionnaire\feedback; +namespace mod_questionnaire\local\feedback; +use mod_questionnaire\local\db\feedback_record; use invalid_parameter_exception; use coding_exception; @@ -54,10 +55,11 @@ class sectionfeedback { public function __construct($id = 0, $record = null) { // Return a new section based on the data id. if ($id != 0) { - $record = $this->get_sectionfeedback($id); - if (!$record) { + $persistent = feedback_record::get_record(['id' => $id]); + if (!$persistent) { throw new invalid_parameter_exception('No section feedback exists with that ID.'); } + $record = $persistent->to_record(); } if (($id != 0) || is_object($record)) { $this->loadproperties($record); @@ -70,7 +72,6 @@ public function __construct($id = 0, $record = null) { * @return sectionfeedback */ public static function new_sectionfeedback($data) { - global $DB; $newsf = new self(); $newsf->sectionid = $data->sectionid; $newsf->feedbacklabel = $data->feedbacklabel; @@ -78,8 +79,18 @@ public static function new_sectionfeedback($data) { $newsf->feedbacktextformat = $data->feedbacktextformat; $newsf->minscore = $data->minscore; $newsf->maxscore = $data->maxscore; - $newsfid = $DB->insert_record(self::TABLE, $newsf); - $newsf->id = $newsfid; + + $record = new feedback_record(0, (object)[ + 'sectionid' => $newsf->sectionid, + 'feedbacklabel' => $newsf->feedbacklabel, + 'feedbacktext' => $newsf->feedbacktext, + 'feedbacktextformat' => $newsf->feedbacktextformat, + 'minscore' => $newsf->minscore, + 'maxscore' => $newsf->maxscore, + ]); + $record->create(); + + $newsf->id = $record->get('id'); return $newsf; } @@ -90,20 +101,15 @@ public static function new_sectionfeedback($data) { * @throws coding_exception */ public function update() { - global $DB; - - $DB->update_record(self::TABLE, $this); - } - - /** - * Return the record specified by the id. - * @param int $id - * @return mixed - */ - protected function get_sectionfeedback($id) { - global $DB; - - return $DB->get_record(self::TABLE, ['id' => $id]); + $record = new feedback_record($this->id, (object)[ + 'sectionid' => $this->sectionid, + 'feedbacklabel' => $this->feedbacklabel, + 'feedbacktext' => $this->feedbacktext, + 'feedbacktextformat' => $this->feedbacktextformat, + 'minscore' => $this->minscore, + 'maxscore' => $this->maxscore, + ]); + $record->update(); } /** @@ -118,17 +124,4 @@ protected function loadproperties($record) { } } } - - /** - * Get the data for this section's feedback from the database. - * - * @param int $id - * @return mixed - * @throws \dml_exception - */ - protected function get_section($id) { - global $DB; - - return $DB->get_record(self::TABLE, ['id' => $id]); - } } diff --git a/classes/question/check.php b/classes/local/question/check.php similarity index 84% rename from classes/question/check.php rename to classes/local/question/check.php index 124bf1b0d..510de2c57 100644 --- a/classes/question/check.php +++ b/classes/local/question/check.php @@ -14,7 +14,7 @@ // You should have received a copy of the GNU General Public License // along with Moodle. If not, see . -namespace mod_questionnaire\question; +namespace mod_questionnaire\local\question; /** * This file contains the parent class for check question types. @@ -30,7 +30,7 @@ class check extends question { * @return string */ protected function responseclass() { - return '\\mod_questionnaire\\responsetype\\multiple'; + return '\\mod_questionnaire\\local\\response\\multiple'; } /** @@ -74,7 +74,7 @@ public function allows_dependents() { /** * Return the context tags for the check question template. - * @param \mod_questionnaire\responsetype\response\response $response + * @param \mod_questionnaire\local\response\response $response * @param array $dependants Array of all questions/choices depending on this question. * @param boolean $blankquestionnaire * @return \stdClass The check question context tags. @@ -85,18 +85,18 @@ protected function question_survey_display($response, $dependants, $blankquestio $otherempty = false; if (!empty($response)) { // Verify that number of checked boxes (nbboxes) is within set limits (length = min; precision = max). - if (!empty($response->answers[$this->id])) { + if (!empty($response->answers[$this->id()])) { $otherempty = false; - $nbboxes = count($response->answers[$this->id]); - foreach ($response->answers[$this->id] as $answer) { + $nbboxes = count($response->answers[$this->id()]); + foreach ($response->answers[$this->id()] as $answer) { $choice = $this->choices[$answer->choiceid]; if ($choice->is_other_choice()) { $otherempty = empty($answer->value); } } $nbchoices = count($this->choices); - $min = $this->length; - $max = $this->precise; + $min = $this->length(); + $max = $this->precise(); if ($max == 0) { $max = $nbchoices; } @@ -129,12 +129,12 @@ protected function question_survey_display($response, $dependants, $blankquestio $choicetags->qelements = []; foreach ($this->choices as $id => $choice) { $checkbox = new \stdClass(); - $contents = questionnaire_choice_values($choice->content); + $contents = question::parse_choice_content($choice->content); $checked = false; - if (!empty($response->answers[$this->id])) { - $checked = isset($response->answers[$this->id][$id]); + if (!empty($response->answers[$this->id()])) { + $checked = isset($response->answers[$this->id()][$id]); } - $checkbox->name = 'q' . $this->id . '[' . $id . ']'; + $checkbox->name = 'q' . $this->id() . '[' . $id . ']'; $checkbox->value = $id; $checkbox->id = 'checkbox_' . $id; $checkbox->label = format_text($contents->text, FORMAT_HTML, ['noclean' => true]) . $contents->image; @@ -142,9 +142,9 @@ protected function question_survey_display($response, $dependants, $blankquestio $checkbox->checked = $checked; } if ($choice->is_other_choice()) { - $checkbox->oname = 'q' . $this->id . '[' . $choice->other_choice_name() . ']'; - $checkbox->ovalue = (isset($response->answers[$this->id][$id]) && !empty($response->answers[$this->id][$id]) ? - format_string(stripslashes($response->answers[$this->id][$id]->value)) : ''); + $checkbox->oname = 'q' . $this->id() . '[' . $choice->other_choice_name() . ']'; + $checkbox->ovalue = (isset($response->answers[$this->id()][$id]) && !empty($response->answers[$this->id()][$id]) ? + format_string(stripslashes($response->answers[$this->id()][$id]->value)) : ''); $checkbox->label = format_text($choice->other_choice_display() . '', FORMAT_HTML, ['noclean' => true]); } if (!empty($this->qlegend)) { @@ -160,7 +160,7 @@ protected function question_survey_display($response, $dependants, $blankquestio /** * Return the context tags for the check response template. - * @param \mod_questionnaire\responsetype\response\response $response + * @param \mod_questionnaire\local\response\response $response * @return \stdClass The check question response context tags. */ protected function response_survey_display($response) { @@ -169,16 +169,16 @@ protected function response_survey_display($response) { $resptags = new \stdClass(); $resptags->choices = []; - if (!isset($response->answers[$this->id])) { - $response->answers[$this->id][] = new \mod_questionnaire\responsetype\answer\answer(); + if (!isset($response->answers[$this->id()])) { + $response->answers[$this->id()][] = new \mod_questionnaire\local\response\answer\answer(); } foreach ($this->choices as $id => $choice) { $chobj = new \stdClass(); if (!$choice->is_other_choice()) { - $contents = questionnaire_choice_values($choice->content); + $contents = question::parse_choice_content($choice->content); $choice->content = $contents->text . $contents->image; - if (isset($response->answers[$this->id][$id])) { + if (isset($response->answers[$this->id()][$id])) { $chobj->selected = 1; } $chobj->name = $id . $uniquetag++; @@ -191,8 +191,8 @@ protected function response_survey_display($response) { ); } else { $othertext = $choice->other_choice_display(); - if (isset($response->answers[$this->id][$id])) { - $oresp = $response->answers[$this->id][$id]->value; + if (isset($response->answers[$this->id()][$id])) { + $oresp = $response->answers[$this->id()][$id]->value; $chobj->selected = 1; $chobj->othercontent = (!empty($oresp) ? htmlspecialchars($oresp) : ' '); } @@ -215,10 +215,10 @@ protected function response_survey_display($response) { */ public function response_complete($responsedata) { if ( - isset($responsedata->{'q' . $this->id}) && $this->required() && - is_array($responsedata->{'q' . $this->id}) + isset($responsedata->{'q' . $this->id()}) && $this->required() && + is_array($responsedata->{'q' . $this->id()}) ) { - foreach ($responsedata->{'q' . $this->id} as $key => $choice) { + foreach ($responsedata->{'q' . $this->id()} as $key => $choice) { // If only an 'other' choice is selected and empty, question is not completed. if ((strpos($key, 'o') === 0) && empty($choice)) { return false; @@ -239,10 +239,10 @@ public function response_complete($responsedata) { public function response_valid($responsedata) { $nbrespchoices = 0; $valid = true; - if (is_a($responsedata, 'mod_questionnaire\responsetype\response\response')) { + if (is_a($responsedata, 'mod_questionnaire\local\response\response')) { // If $responsedata is a response object, look through the answers. - if (isset($responsedata->answers[$this->id]) && !empty($responsedata->answers[$this->id])) { - foreach ($responsedata->answers[$this->id] as $answer) { + if (isset($responsedata->answers[$this->id()]) && !empty($responsedata->answers[$this->id()])) { + foreach ($responsedata->answers[$this->id()] as $answer) { if (isset($this->choices[$answer->choiceid]) && $this->choices[$answer->choiceid]->is_other_choice()) { $valid = !empty($answer->value); } else { @@ -250,12 +250,12 @@ public function response_valid($responsedata) { } } } - } else if (isset($responsedata->{'q' . $this->id})) { - foreach ($responsedata->{'q' . $this->id} as $key => $answer) { + } else if (isset($responsedata->{'q' . $this->id()})) { + foreach ($responsedata->{'q' . $this->id()} as $key => $answer) { if (strpos($key, 'o') === 0) { // ..."other" choice is checked but text box is empty. $okey = substr($key, 1); - if (isset($responsedata->{'q' . $this->id}[$okey]) && empty(trim($answer))) { + if (isset($responsedata->{'q' . $this->id()}[$okey]) && empty(trim($answer))) { $valid = false; break; } @@ -268,8 +268,8 @@ public function response_valid($responsedata) { } $nbquestchoices = count($this->choices); - $min = $this->length; - $max = $this->precise; + $min = $this->length(); + $max = $this->precise(); if ($max == 0) { $max = $nbquestchoices; } @@ -375,8 +375,8 @@ public function mobile_question_choices_display() { */ public function get_mobile_response_data($response) { $resultdata = []; - if (isset($response->answers[$this->id])) { - foreach ($response->answers[$this->id] as $answer) { + if (isset($response->answers[$this->id()])) { + foreach ($response->answers[$this->id()] as $answer) { if (isset($this->choices[$answer->choiceid])) { // Add a fieldkey for each choice. $resultdata[$this->mobile_fieldkey($answer->choiceid)] = 1; diff --git a/classes/question/choice.php b/classes/local/question/choice.php similarity index 86% rename from classes/question/choice.php rename to classes/local/question/choice.php index 663e074a1..d5d091305 100644 --- a/classes/question/choice.php +++ b/classes/local/question/choice.php @@ -14,7 +14,7 @@ // You should have received a copy of the GNU General Public License // along with Moodle. If not, see . -namespace mod_questionnaire\question; +namespace mod_questionnaire\local\question; /** * This defines a structured class to hold question choices. @@ -63,15 +63,11 @@ public function __construct($id = null, $questionid = null, $content = null, $va * @return choice */ public static function create_from_id($id) { - global $DB; - - // Rename the data field question_id to questionid to conform with code conventions. Eventually, data table should be - // changed. - if ($record = $DB->get_record(self::tablename(), ['id' => $id], 'id,question_id as questionid,content,value')) { - return new choice($id, $record->questionid, $record->content, $record->value); - } else { - return new choice(); + $choicerec = \mod_questionnaire\local\db\choice_record::get_record(['id' => $id]); + if ($choicerec) { + return new choice($id, $choicerec->get('questionid'), $choicerec->get('content'), $choicerec->get('value')); } + return new choice(); } /** @@ -91,11 +87,6 @@ public static function create_from_data($choicedata) { $choicedata[$property] = null; } } - // Since the data table uses 'question_id' instead of 'questionid', look for that field as well. Hack that should be fixed - // by renaming the data table column. - if (!empty($choicedata['question_id'])) { - $choicedata['questionid'] = $choicedata['question_id']; - } return new choice($choicedata['id'], $choicedata['questionid'], $choicedata['content'], $choicedata['value']); } @@ -113,8 +104,12 @@ public static function tablename() { * @return bool */ public static function delete_from_db_by_id($id) { - global $DB; - return $DB->delete_records(self::tablename(), ['id' => $id]); + $choicerec = \mod_questionnaire\local\db\choice_record::get_record(['id' => $id]); + if ($choicerec) { + $choicerec->delete(); + return true; + } + return false; } /** diff --git a/classes/question/date.php b/classes/local/question/date.php similarity index 85% rename from classes/question/date.php rename to classes/local/question/date.php index bf8863a01..8fec8a478 100644 --- a/classes/question/date.php +++ b/classes/local/question/date.php @@ -14,7 +14,7 @@ // You should have received a copy of the GNU General Public License // along with Moodle. If not, see . -namespace mod_questionnaire\question; +namespace mod_questionnaire\local\question; /** * This file contains the parent class for date question types. @@ -30,7 +30,7 @@ class date extends question { * @return string */ protected function responseclass() { - return '\\mod_questionnaire\\responsetype\\date'; + return '\\mod_questionnaire\\local\\response\\date'; } /** @@ -59,7 +59,7 @@ public function response_template() { /** * Return the context tags for the check question template. - * @param \mod_questionnaire\responsetype\response\response $response + * @param \mod_questionnaire\local\response\response $response * @param array $descendantsdata * @param boolean $blankquestionnaire * @return object The check question context tags. @@ -67,21 +67,21 @@ public function response_template() { protected function question_survey_display($response, $descendantsdata, $blankquestionnaire = false) { // Date. $questiontags = new \stdClass(); - if (!empty($response->answers[$this->id])) { - $dateentered = $response->answers[$this->id][0]->value; + if (!empty($response->answers[$this->id()])) { + $dateentered = $response->answers[$this->id()][0]->value; $setdate = $this->check_date_format($dateentered); if (!$setdate) { $msg = get_string('wrongdateformat', 'questionnaire', $dateentered); $this->add_notification($msg); } else { - $response->answers[$this->id][0]->value = $dateentered; + $response->answers[$this->id()][0]->value = $dateentered; } } $choice = new \stdClass(); $choice->type = 'date'; // Using HTML5 date input. $choice->onkeypress = 'return event.keyCode != 13;'; - $choice->name = 'q' . $this->id; - $choice->value = (isset($response->answers[$this->id][0]->value) ? $response->answers[$this->id][0]->value : ''); + $choice->name = 'q' . $this->id(); + $choice->value = (isset($response->answers[$this->id()][0]->value) ? $response->answers[$this->id()][0]->value : ''); $questiontags->qelements = new \stdClass(); $questiontags->qelements->choice = $choice; $questiontags->isprint = $this->get_isprint(); @@ -90,13 +90,13 @@ protected function question_survey_display($response, $descendantsdata, $blankqu /** * Return the context tags for the check response template. - * @param \mod_questionnaire\responsetype\response\response $response + * @param \mod_questionnaire\local\response\response $response * @return object The check question response context tags. */ protected function response_survey_display($response) { $resptags = new \stdClass(); - if (isset($response->answers[$this->id])) { - $answer = reset($response->answers[$this->id]); + if (isset($response->answers[$this->id()])) { + $answer = reset($response->answers[$this->id()]); $resptags->content = $answer->value; } return $resptags; @@ -110,14 +110,14 @@ protected function response_survey_display($response) { */ public function response_valid($responsedata) { $responseval = false; - if (is_a($responsedata, 'mod_questionnaire\responsetype\response\response')) { + if (is_a($responsedata, 'mod_questionnaire\local\response\response')) { // If $responsedata is a response object, look through the answers. - if (isset($responsedata->answers[$this->id]) && !empty($responsedata->answers[$this->id])) { - $answer = $responsedata->answers[$this->id][0]; + if (isset($responsedata->answers[$this->id()]) && !empty($responsedata->answers[$this->id()])) { + $answer = $responsedata->answers[$this->id()][0]; $responseval = $answer->value; } - } else if (isset($responsedata->{'q' . $this->id})) { - $responseval = $responsedata->{'q' . $this->id}; + } else if (isset($responsedata->{'q' . $this->id()})) { + $responseval = $responsedata->{'q' . $this->id()}; } if ($responseval !== false) { $checkdateresult = true; @@ -223,8 +223,8 @@ public function mobile_question_choices_display() { $choices = []; $choices[0] = new \stdClass(); $choices[0]->id = 0; - $choices[0]->choice_id = 0; - $choices[0]->question_id = $this->id; + $choices[0]->choiceid = 0; + $choices[0]->questionid = $this->id(); $choices[0]->content = ''; $choices[0]->value = null; return $choices; diff --git a/classes/question/drop.php b/classes/local/question/drop.php similarity index 85% rename from classes/question/drop.php rename to classes/local/question/drop.php index f611aaad7..7dbba666a 100644 --- a/classes/question/drop.php +++ b/classes/local/question/drop.php @@ -14,10 +14,10 @@ // You should have received a copy of the GNU General Public License // along with Moodle. If not, see . -namespace mod_questionnaire\question; +namespace mod_questionnaire\local\question; use html_writer; -use mod_questionnaire\question\choice; -use mod_questionnaire\responsetype\response\response; +use mod_questionnaire\local\question\choice; +use mod_questionnaire\local\response\response; /** * This file contains the parent class for drop question types. @@ -33,7 +33,7 @@ class drop extends question { * @return string The response object based off of questionnaire_response_base. */ protected function responseclass() { - return '\\mod_questionnaire\\responsetype\\single'; + return '\\mod_questionnaire\\local\\response\\single'; } /** @@ -85,7 +85,7 @@ public function supports_feedback() { /** * Return the context tags for the check question template. - * @param \mod_questionnaire\responsetype\response\response $response + * @param \mod_questionnaire\local\response\response $response * @param array $dependants Array of all questions/choices depending on this question. * @param boolean $blankquestionnaire * @return object The check question context tags. @@ -105,15 +105,15 @@ protected function question_survey_display($response, $dependants, $blankquestio $option = new \stdClass(); $option->value = $key; $option->label = format_string($choice->content); - if (isset($response->answers[$this->id][$key])) { + if (isset($response->answers[$this->id()][$key])) { $option->selected = true; } $options[] = $option; } $chobj = new \stdClass(); - $chobj->name = 'q' . $this->id; - $chobj->id = self::qtypename($this->type_id) . $this->name; - $chobj->class = 'select form-select menu q' . $this->id; + $chobj->name = 'q' . $this->id(); + $chobj->id = self::qtypename($this->typeid()) . $this->name(); + $chobj->class = 'select form-select menu q' . $this->id(); $chobj->options = $options; $choicetags->qelements->choice = $chobj; $choicetags->isprint = $this->get_isprint(); @@ -123,29 +123,29 @@ protected function question_survey_display($response, $dependants, $blankquestio /** * Return the context tags for the drop response template. - * @param \mod_questionnaire\responsetype\response\response $response + * @param \mod_questionnaire\local\response\response $response * @return \stdClass The check question response context tags. */ protected function response_survey_display($response) { static $uniquetag = 0; // To make sure all radios have unique names. $resptags = new \stdClass(); - $resptags->name = 'q' . $this->id . $uniquetag++; + $resptags->name = 'q' . $this->id() . $uniquetag++; $resptags->id = 'menu' . $resptags->name; $resptags->class = 'select form-select ' . $resptags->id; $resptags->options = []; $resptags->options[] = (object)['value' => '', 'label' => get_string('choosedots')]; - if (!isset($response->answers[$this->id])) { - $response->answers[$this->id][] = new \mod_questionnaire\responsetype\answer\answer(); + if (!isset($response->answers[$this->id()])) { + $response->answers[$this->id()][] = new \mod_questionnaire\local\response\answer\answer(); } foreach ($this->choices as $id => $choice) { - $contents = questionnaire_choice_values($choice->content); + $contents = question::parse_choice_content($choice->content); $chobj = new \stdClass(); $chobj->value = $id; $chobj->label = format_text($contents->text, FORMAT_HTML, ['noclean' => true]); - if (isset($response->answers[$this->id][$id])) { + if (isset($response->answers[$this->id()][$id])) { $chobj->selected = 1; $resptags->selectedlabel = $chobj->label; } @@ -200,8 +200,8 @@ public function mobile_question_display($qnum, $autonum = false) { */ public function get_mobile_response_data($response) { $resultdata = []; - if (isset($response->answers[$this->id])) { - foreach ($response->answers[$this->id] as $answer) { + if (isset($response->answers[$this->id()])) { + foreach ($response->answers[$this->id()] as $answer) { // Add a fieldkey for each choice. $resultdata[$this->mobile_fieldkey()] = $answer->choiceid; } diff --git a/classes/question/essay.php b/classes/local/question/essay.php similarity index 87% rename from classes/question/essay.php rename to classes/local/question/essay.php index 45e0cc955..446a68c5e 100644 --- a/classes/question/essay.php +++ b/classes/local/question/essay.php @@ -14,9 +14,9 @@ // You should have received a copy of the GNU General Public License // along with Moodle. If not, see . -namespace mod_questionnaire\question; +namespace mod_questionnaire\local\question; use html_writer; -use mod_questionnaire\responsetype\response\response; +use mod_questionnaire\local\response\response; /** * This file contains the parent class for essay question types. @@ -32,7 +32,7 @@ class essay extends text { * @return object The response object based off of questionnaire_response_base. */ protected function responseclass() { - return '\\mod_questionnaire\\responsetype\\text'; + return '\\mod_questionnaire\\local\\response\\text'; } /** @@ -74,23 +74,23 @@ protected function question_survey_display($response, $descendantsdata, $blankqu $cols = 80; $rows = 15; // Use HTML editor or not? - if ($this->precise == 0) { + if ($this->precise() == 0) { $canusehtmleditor = true; - $rows = $this->length == 0 ? $rows : $this->length; + $rows = $this->length() == 0 ? $rows : $this->length(); } else { $canusehtmleditor = false; // Prior to version 2.6, "precise" was used for rows number. - $rows = $this->precise > 1 ? $this->precise : $this->length; + $rows = $this->precise() > 1 ? $this->precise() : $this->length(); } - $name = 'q' . $this->id; - if (isset($response->answers[$this->id][0])) { - $value = $response->answers[$this->id][0]->value; + $name = 'q' . $this->id(); + if (isset($response->answers[$this->id()][0])) { + $value = $response->answers[$this->id()][0]->value; } else { $value = ''; } if ($canusehtmleditor && !$this->get_isprint()) { $editor = editors_get_preferred_editor(); - $editor->use_editor($name, questionnaire_get_editor_options($this->context)); + $editor->use_editor($name, \mod_questionnaire\form_options::editor($this->context)); $texteditor = html_writer::tag( 'textarea', $value, @@ -114,8 +114,8 @@ protected function question_survey_display($response, $descendantsdata, $blankqu * */ protected function response_survey_display($response) { - if (isset($response->answers[$this->id])) { - $answer = reset($response->answers[$this->id]); + if (isset($response->answers[$this->id()])) { + $answer = reset($response->answers[$this->id()]); $answer = format_text($answer->value, FORMAT_HTML); } else { $answer = ' '; diff --git a/classes/question/file.php b/classes/local/question/file.php similarity index 87% rename from classes/question/file.php rename to classes/local/question/file.php index 5622b4d48..375c6f763 100644 --- a/classes/question/file.php +++ b/classes/local/question/file.php @@ -14,10 +14,10 @@ // You should have received a copy of the GNU General Public License // along with Moodle. If not, see . -namespace mod_questionnaire\question; +namespace mod_questionnaire\local\question; use core_media_manager; use form_filemanager; -use mod_questionnaire\responsetype\response\response; +use mod_questionnaire\local\response\response; use moodle_url; use MoodleQuickForm; @@ -64,7 +64,7 @@ public function response_template() { * @return object|string */ protected function responseclass() { - return '\\mod_questionnaire\\responsetype\\file'; + return '\\mod_questionnaire\\local\\response\\file'; } /** @@ -79,14 +79,14 @@ protected function question_survey_display($response, $descendantsdata, $blankqu global $CFG, $PAGE; require_once($CFG->libdir . '/filelib.php'); - $elname = 'q' . $this->id; + $elname = 'q' . $this->id(); // If there is a response and the response is resumed, get the original itemid. $itemid = ( - isset($response->answers[$this->id]) && - !empty($response->answers[$this->id]) && + isset($response->answers[$this->id()]) && + !empty($response->answers[$this->id()]) && isset($_REQUEST['resume']) && $_REQUEST['resume'] === '1' - ) ? (int)$response->answers[$this->id][0]->id : 0; + ) ? (int)$response->answers[$this->id()][0]->id : 0; // Prepare the draft area. $draftitemid = file_get_submitted_draft_itemid($elname); file_prepare_draft_area( @@ -117,20 +117,20 @@ 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]) + is_a($responsedata, 'mod_questionnaire\local\response\response') && + isset($responsedata->answers[$this->id()]) && + !empty($responsedata->answers[$this->id()]) ) { - $answer = reset($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}; + } 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); + return !($this->required() && ($this->deleted() == 'n') && !$answered); } /** @@ -159,8 +159,8 @@ 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]); + if (isset($data->answers[$this->id()])) { + $answer = reset($data->answers[$this->id()]); } else { return ''; } diff --git a/classes/question/numerical.php b/classes/local/question/numerical.php similarity index 80% rename from classes/question/numerical.php rename to classes/local/question/numerical.php index 19f8318f5..bb42bd3dd 100644 --- a/classes/question/numerical.php +++ b/classes/local/question/numerical.php @@ -14,7 +14,7 @@ // You should have received a copy of the GNU General Public License // along with Moodle. If not, see . -namespace mod_questionnaire\question; +namespace mod_questionnaire\local\question; /** * This file contains the parent class for numeric question types. @@ -33,7 +33,7 @@ class numerical extends question { * @param array $params */ public function __construct($id = 0, $question = null, $context = null, $params = []) { - $this->length = 10; + $this->set_length(10); return parent::__construct($id, $question, $context, $params); } @@ -44,7 +44,7 @@ public function __construct($id = 0, $question = null, $context = null, $params * */ protected function responseclass() { - return '\\mod_questionnaire\\responsetype\\numericaltext'; + return '\\mod_questionnaire\\local\\response\\numericaltext'; } /** @@ -73,7 +73,7 @@ public function response_template() { /** * Return the context tags for the check question template. - * @param \mod_questionnaire\responsetype\response\response $response + * @param \mod_questionnaire\local\response\response $response * @param array $descendantsdata * @param boolean $blankquestionnaire * @return \stdClass The check question context tags. @@ -81,10 +81,10 @@ public function response_template() { protected function question_survey_display($response, $descendantsdata, $blankquestionnaire = false) { // Numeric. $questiontags = new \stdClass(); - $precision = $this->precise; + $precision = $this->precise(); $a = new \stdClass(); - if (isset($response->answers[$this->id][0])) { - $mynumber = $response->answers[$this->id][0]->value; + if (isset($response->answers[$this->id()][0])) { + $mynumber = $response->answers[$this->id()][0]->value; if ($mynumber != '') { $mynumber0 = $mynumber; if (!is_numeric($mynumber)) { @@ -94,11 +94,11 @@ protected function question_survey_display($response, $descendantsdata, $blankqu if ($precision) { $pos = strpos($mynumber, '.'); if (!$pos) { - if (strlen($mynumber) > $this->length) { - $mynumber = substr($mynumber, 0, $this->length); + if (strlen($mynumber) > $this->length()) { + $mynumber = substr($mynumber, 0, $this->length()); } } - $this->length += (1 + $precision); // To allow for n numbers after decimal point. + $this->set_length($this->length() + (1 + $precision)); // To allow for n numbers after decimal point. } $mynumber = number_format($mynumber, $precision, '.', ''); if ($mynumber != $mynumber0) { @@ -110,19 +110,19 @@ protected function question_survey_display($response, $descendantsdata, $blankqu } } if ($mynumber != '') { - $response->answers[$this->id][0]->value = $mynumber; + $response->answers[$this->id()][0]->value = $mynumber; } } $choice = new \stdClass(); $choice->onkeypress = 'return event.keyCode != 13;'; - $choice->size = $this->length; + $choice->size = $this->length(); // Add a 'thousands separator' instruction if there is a size setting greater than three. $choice->instruction = (empty($choice->size) || ($choice->size > 3)) ? get_string('thousands', 'mod_questionnaire') : ''; - $choice->name = 'q' . $this->id; - $choice->maxlength = $this->length; - $choice->value = (isset($response->answers[$this->id][0]) ? $response->answers[$this->id][0]->value : ''); - $choice->id = self::qtypename($this->type_id) . $this->id; + $choice->name = 'q' . $this->id(); + $choice->maxlength = $this->length(); + $choice->value = (isset($response->answers[$this->id()][0]) ? $response->answers[$this->id()][0]->value : ''); + $choice->id = self::qtypename($this->typeid()) . $this->id(); $questiontags->qelements = new \stdClass(); $questiontags->qelements->choice = $choice; return $questiontags; @@ -136,14 +136,14 @@ protected function question_survey_display($response, $descendantsdata, $blankqu */ public function response_valid($responsedata) { $responseval = false; - if (is_a($responsedata, 'mod_questionnaire\responsetype\response\response')) { + if (is_a($responsedata, 'mod_questionnaire\local\response\response')) { // If $responsedata is a response object, look through the answers. - if (isset($responsedata->answers[$this->id]) && !empty($responsedata->answers[$this->id])) { - $answer = $responsedata->answers[$this->id][0]; + if (isset($responsedata->answers[$this->id()]) && !empty($responsedata->answers[$this->id()])) { + $answer = $responsedata->answers[$this->id()][0]; $responseval = $answer->value; } - } else if (isset($responsedata->{'q' . $this->id})) { - $responseval = $responsedata->{'q' . $this->id}; + } else if (isset($responsedata->{'q' . $this->id()})) { + $responseval = $responsedata->{'q' . $this->id()}; } if ($responseval !== false) { // If commas are present, replace them with periods, in case that was meant as the European decimal place. @@ -156,13 +156,13 @@ public function response_valid($responsedata) { /** * Return the context tags for the numeric response template. - * @param \mod_questionnaire\responsetype\response\response $response + * @param \mod_questionnaire\local\response\response $response * @return \stdClass The numeric question response context tags. */ protected function response_survey_display($response) { $resptags = new \stdClass(); - if (isset($response->answers[$this->id])) { - $answer = reset($response->answers[$this->id]); + if (isset($response->answers[$this->id()])) { + $answer = reset($response->answers[$this->id()]); $resptags->content = $answer->value; } return $resptags; @@ -174,7 +174,7 @@ protected function response_survey_display($response) { * @param string $helptext */ protected function form_length(\MoodleQuickForm $mform, $helptext = '') { - $this->length = isset($this->length) ? $this->length : 10; + $this->set_length($this->length() ?: 10); return parent::form_length($mform, 'maxdigitsallowed'); } @@ -215,8 +215,8 @@ public function mobile_question_choices_display() { $choices = []; $choices[0] = new \stdClass(); $choices[0]->id = 0; - $choices[0]->choice_id = 0; - $choices[0]->question_id = $this->id; + $choices[0]->choiceid = 0; + $choices[0]->questionid = $this->id(); $choices[0]->content = ''; $choices[0]->value = null; return $choices; diff --git a/classes/question/pagebreak.php b/classes/local/question/pagebreak.php similarity index 81% rename from classes/question/pagebreak.php rename to classes/local/question/pagebreak.php index 596de4bc5..112d6d2a4 100644 --- a/classes/question/pagebreak.php +++ b/classes/local/question/pagebreak.php @@ -14,9 +14,9 @@ // You should have received a copy of the GNU General Public License // along with Moodle. If not, see . -namespace mod_questionnaire\question; +namespace mod_questionnaire\local\question; use mod_questionnaire\edit_question_form; -use questionnaire; +use mod_questionnaire\questionnaire; /** * This file contains the parent class for pagebreak question types. @@ -46,11 +46,18 @@ public function helpname() { /** * Get the output for the start of the questions in a survey. * @param int $qnum - * @param \mod_questionnaire\responsetype\response\response $response + * @param \mod_questionnaire\local\response\response $response + * @param \questionnaire|null $questionnaire + * @param bool $individualresponse Unused; signature must match parent. * @return \stdClass */ - public function questionstart_survey_display($qnum, $response = null) { - return ''; + public function questionstart_survey_display( + $qnum, + $response = null, + $questionnaire = null, + bool $individualresponse = false + ): \stdClass { + return new \stdClass(); } /** @@ -77,10 +84,10 @@ protected function response_survey_display($data) { * Override this, or any of the internal methods, to provide specific form data for editing the question type. * The structure of the elements here is the default layout for the question form. * @param edit_question_form $form The main moodleform object. - * @param questionnaire $questionnaire The questionnaire being edited. + * @param \mod_questionnaire\questionnaire $questionnaire The questionnaire being edited. * @return bool */ - public function edit_form(edit_question_form $form, questionnaire $questionnaire) { + public function edit_form(edit_question_form $form, \mod_questionnaire\questionnaire $questionnaire) { return false; } diff --git a/classes/question/question.php b/classes/local/question/question.php similarity index 66% rename from classes/question/question.php rename to classes/local/question/question.php index 0ae9937a3..09ceb9c22 100644 --- a/classes/question/question.php +++ b/classes/local/question/question.php @@ -23,11 +23,15 @@ * @package mod_questionnaire */ -namespace mod_questionnaire\question; +namespace mod_questionnaire\local\question; use mod_questionnaire\edit_question_form; -use mod_questionnaire\responsetype\response\response; -use questionnaire; +use mod_questionnaire\local\db\question_record; +use mod_questionnaire\local\db\question_type_record; +use mod_questionnaire\local\question_type; +use mod_questionnaire\local\response\response; +use mod_questionnaire\questionnaire; use html_writer; +use stdClass; defined('MOODLE_INTERNAL') || die(); @@ -47,12 +51,9 @@ define('QUESPAGEBREAK', 99); define('QUESSECTIONTEXT', 100); -global $idcounter, $CFG; +global $idcounter; $idcounter = 0; -require_once($CFG->dirroot . '/mod/questionnaire/locallib.php'); - -#[\AllowDynamicProperties] /** * Class for describing a question * @@ -62,17 +63,33 @@ */ abstract class question { // Class Properties. - /** @var int $id The database id of this question. */ - public $id = 0; - /** @var int $surveyid The database id of the survey this question belongs to. */ - public $surveyid = 0; + /** + * Backing persistent record for all DB columns (surveyid, name, typeid, length, + * precise, position, content, required, deleted, extradata, resultid). + * Null only briefly before the constructor initialises it; set to a new empty + * record in __set so that subclass pre-constructor default assignments are buffered. + * + * @var question_record|null + */ + protected ?question_record $record = null; - /** @var string $name The name of this question. */ - public $name = ''; + /** + * The question_type for this question (loaded from questionnaire_question_type table). + * Provides $type (name string), $haschoices and $responsetable. + * + * @var question_type|null + */ + protected ?question_type $questiontype = null; - /** @var string $type The name of the question type. */ - public $type = ''; + /** + * Buffer for DB-column values assigned by subclass constructors (via set_length() etc.) + * before parent::__construct() has had a chance to initialise $this->record. + * Applied to the record for new questions only; ignored when loading from DB. + * + * @var array + */ + private array $preinit = []; /** @var array $choices Array holding any choices for this question. */ public $choices = []; @@ -80,59 +97,68 @@ abstract class question { /** @var array $dependencies Array holding any dependencies for this question. */ public $dependencies = []; - /** @var string $responsetable The table name for responses. */ - public $responsetable = ''; + /** @var string $qlegend The question's legend. */ + public $qlegend = ''; - /** @var int $length The length field. */ - public $length = 0; + /** @var string $allchoices The list of all question's choices. */ + public $allchoices = ''; - /** @var int $precise The precision field. */ - public $precise = 0; + /** @var bool $isprint The isprint flag. */ + public $isprint = false; - /** @var int $position Position in the questionnaire */ - public $position = 0; + /** @var \context|null $context The module context. */ + public $context = null; - /** @var string $content The question's content. */ - public $content = ''; + /** @var object|null $responsetype The response type handler for this question. */ + public $responsetype = null; - /** @var string $qlegend The question's legend. */ - public $qlegend = ''; + /** @var int|null $qid The id assigned after inserting a new question record. */ + public $qid = null; - /** @var string $allchoices The list of all question's choices. */ - public $allchoices = ''; + /** @var mixed $resultid Legacy response result id (not loaded from DB; set transiently). */ + public $resultid = null; - /** @var bool $required The required flag. */ - public $required = 'n'; + /** @var mixed $dependquestion Legacy dependency question id. */ + public $dependquestion = null; - /** @var int $deleted The deleted flag. */ - public $deleted = null; + /** @var mixed $dependchoice Legacy dependency choice id. */ + public $dependchoice = null; - /** @var mixed $extradata Any custom data for the question type. */ - public $extradata = ''; + /** @var int|null $sid The survey id, set by locallib when building question edit forms. */ + public $sid = null; - /** @var bool $isprint The isprint flag. */ - public $isprint = false; + /** @var array $dependquestionsand Dependency question+choice ids for "and" logic (locallib form builder). */ + public $dependquestionsand = []; + + /** @var array $dependlogicand Dependency logic values for "and" conditions (locallib form builder). */ + public $dependlogicand = []; - /** @var array $qtypenames List of all question names. */ - private static $qtypenames = [ - QUESYESNO => 'yesno', - QUESTEXT => 'text', - QUESESSAY => 'essay', - QUESRADIO => 'radio', - QUESCHECK => 'check', - QUESDROP => 'drop', - QUESRATE => 'rate', - QUESDATE => 'date', - QUESFILE => 'file', - QUESNUMERIC => 'numerical', - QUESPAGEBREAK => 'pagebreak', - QUESSECTIONTEXT => 'sectiontext', - QUESSLIDER => 'slider', - ]; + /** @var array $dependquestionsor Dependency question+choice ids for "or" logic (locallib form builder). */ + public $dependquestionsor = []; + + /** @var array $dependlogicor Dependency logic values for "or" conditions (locallib form builder). */ + public $dependlogicor = []; /** @var array $notifications Array of extra messages for display purposes. */ private $notifications = []; + /** @var \questionnaire|null The parent questionnaire object, set during rendering. */ + protected $questionnaire = null; + + /** + * TEMPORARY: Editor-format content array set by locallib for new/edited questions. + * When content is assigned as ['text'=>..., 'format'=>..., 'itemid'=>...] (Moodle + * editor array), it cannot be stored in the persistent record because Moodle 5's + * persistent::get() runs clean_param() on read and rejects arrays. This property + * holds the array form; __get('content') returns it when set, falling back to the + * DB string otherwise. Cleared when the record is saved. + * + * Will be removed in Phase 22f. + * + * @var array|null + */ + public $contenteditordata = null; + // Class Methods. /** @@ -143,57 +169,343 @@ abstract class question { * @param array $params */ public function __construct($id = 0, $question = null, $context = null, $params = []) { - global $DB; - static $qtypes = null; - - if ($qtypes === null) { - $qtypes = $DB->get_records( - 'questionnaire_question_type', - [], - 'typeid', - 'typeid, type, has_choices, response_table' - ) ?? []; - } - + // Determine which DB-backed properties were pre-set by a subclass constructor + // before this parent constructor was called (e.g. text sets length=20, rate sets + // length=5). Those are held in $this->preinit (see __set) and applied below only + // when constructing a brand-new question with no stored data. if ($id) { - $question = $DB->get_record('questionnaire_question', ['id' => $id]); + // Load existing question from the database. + $loaded = question_record::get_record(['id' => $id]); + $this->record = $loaded ?: new question_record(); + } else if (is_object($question)) { + // Construct from a supplied stdClass (e.g. passed in from a list query). + $this->record = new question_record(0, $question); + } else { + // Brand-new question; start with an empty record and apply any subclass + // defaults that were buffered in $this->preinit before this constructor ran. + $this->record = new question_record(); + foreach ($this->preinit as $field => $default) { + $this->record->set($field, $default); + } } + $this->preinit = []; - if (is_object($question)) { - $this->id = $question->id; - $this->surveyid = $question->surveyid; - $this->name = $question->name; - $this->length = $question->length; - $this->precise = $question->precise; - $this->position = $question->position; - $this->content = $question->content; - $this->required = $question->required; - $this->deleted = $question->deleted; - $this->extradata = $question->extradata; + // Apply caller-supplied overrides ($params keys map to DB columns or declared props). + $dbfields = ['surveyid', 'name', 'typeid', 'length', 'precise', + 'position', 'content', 'required', 'deleted', 'extradata']; + foreach ($params as $property => $value) { + if ($property === 'id') { + $stub = new \stdClass(); + $stub->id = (int)$value; + $this->record->from_record($stub); + } else if ($property === 'content' && is_array($value)) { + $this->contenteditordata = $value; + } else if (in_array($property, $dbfields, true)) { + $this->record->set($property, $value); + } else if (property_exists($this, $property)) { + $this->$property = $value; + } + } - $this->type_id = $question->type_id; - $this->type = $qtypes[$this->type_id]->type; - $this->responsetable = $qtypes[$this->type_id]->response_table; + // Resolve the question_type (cached per typeid for the request lifetime). + $typeid = $this->record->get('typeid'); + if ($typeid) { + $this->questiontype = question_type::from_typeid($typeid); + } + // Load choices and dependencies when there is a persisted record to query. + if ($id || is_object($question)) { if (!empty($question->choices)) { $this->choices = $question->choices; - } else if ($qtypes[$this->type_id]->has_choices == 'y') { + } else if ($this->questiontype && $this->questiontype->haschoices === 'y') { $this->get_choices(); } - // Added for dependencies. $this->get_dependencies(); } - $this->context = $context; - foreach ($params as $property => $value) { - $this->$property = $value; - } + $this->context = $context; if ($respclass = $this->responseclass()) { $this->responsetype = new $respclass($this); } } + // Named accessors for DB-backed fields. + + /** + * Get the question id. + * @return int + */ + public function id(): int { + return $this->record ? (int)$this->record->get('id') : 0; + } + + /** + * Get the survey id this question belongs to. + * @return int + */ + public function surveyid(): int { + return $this->record ? (int)$this->record->get('surveyid') : 0; + } + + /** + * Get the question name. + * @return string + */ + public function name(): string { + return $this->record ? (string)$this->record->get('name') : ''; + } + + /** + * Get the question type id. + * @return int + */ + public function typeid(): int { + return $this->record ? (int)$this->record->get('typeid') : 0; + } + + /** + * Get the question type name string (e.g. 'radio', 'check'), or empty string if not set. + * @return string + */ + public function type(): string { + return $this->questiontype ? $this->questiontype->type : ''; + } + + /** + * Get the response table name for this question type, or null if not applicable. + * @return string|null + */ + public function responsetable(): ?string { + return $this->questiontype ? $this->questiontype->responsetable : null; + } + + /** + * Get the question length value. + * @return int + */ + public function length(): int { + return $this->record ? (int)$this->record->get('length') : 0; + } + + /** + * Get the question precise value. + * @return int + */ + public function precise(): int { + return $this->record ? (int)$this->record->get('precise') : 0; + } + + /** + * Get the question position. + * @return int + */ + public function position(): int { + return $this->record ? (int)$this->record->get('position') : 0; + } + + /** + * Get the question content (may be array when in editor format). + * @return mixed + */ + public function content(): mixed { + return $this->contenteditordata ?? ($this->record ? $this->record->get('content') : null); + } + + /** + * Get the deleted flag. + * @return string + */ + public function deleted(): string { + return $this->record ? (string)$this->record->get('deleted') : 'n'; + } + + /** + * Get the extradata field. + * @return string|null + */ + public function extradata(): ?string { + return $this->record ? $this->record->get('extradata') : null; + } + + // Named setters for DB-backed fields. + + /** + * Set the question id. + * @param int $value + */ + public function set_id(int $value): void { + $stub = new \stdClass(); + $stub->id = $value; + if ($this->record) { + $this->record->from_record($stub); + } else { + $this->preinit['id'] = $value; + } + } + + /** + * Set the survey id. + * @param int $value + */ + public function set_surveyid(int $value): void { + if ($this->record) { + $this->record->set('surveyid', $value); + } else { + $this->preinit['surveyid'] = $value; + } + } + + /** + * Set the question name. + * @param string $value + */ + public function set_name(string $value): void { + if ($this->record) { + $this->record->set('name', $value); + } else { + $this->preinit['name'] = $value; + } + } + + /** + * Set the question type id. + * @param int $value + */ + public function set_typeid(int $value): void { + if ($this->record) { + $this->record->set('typeid', $value); + } else { + $this->preinit['typeid'] = $value; + } + } + + /** + * Set the question position. + * @param int $value + */ + public function set_position(int $value): void { + if ($this->record) { + $this->record->set('position', $value); + } else { + $this->preinit['position'] = $value; + } + } + + /** + * Set the question content. Accepts a string value or an editor-format array. + * @param mixed $value + */ + public function set_content(mixed $value): void { + if (is_array($value)) { + $this->contenteditordata = $value; + } else if ($this->record) { + $this->record->set('content', $value); + } else { + $this->preinit['content'] = $value; + } + } + + /** + * Set the question length value. + * @param int $value + */ + public function set_length(int $value): void { + if ($this->record) { + $this->record->set('length', $value); + } else { + $this->preinit['length'] = $value; + } + } + + /** + * Set the question precise value. + * @param int $value + */ + public function set_precise(int $value): void { + if ($this->record) { + $this->record->set('precise', $value); + } else { + $this->preinit['precise'] = $value; + } + } + + /** + * Set the deleted flag. + * @param string|null $value + */ + public function set_deleted(?string $value): void { + if ($this->record) { + $this->record->set('deleted', $value); + } else { + $this->preinit['deleted'] = $value; + } + } + + /** + * Set the extradata field. + * @param string|null $value + */ + public function set_extradata(?string $value): void { + if ($this->record) { + $this->record->set('extradata', $value); + } else { + $this->preinit['extradata'] = $value; + } + } + + /** + * Set the required field value in memory only (no DB write). + * Accepts bool or raw 'y'/'n' string. + * @param mixed $value + */ + public function set_required_value(mixed $value): void { + $rval = is_bool($value) ? ($value ? 'y' : 'n') : (string)$value; + if ($this->record) { + $this->record->set('required', $rval); + } else { + $this->preinit['required'] = $rval; + } + } + + /** + * Return a plain stdClass containing all form-relevant question properties. + * + * PHP's (array) cast excludes magic __get properties, so moodleform::set_data() + * cannot populate hidden fields (id, typeid, etc.) from a question object directly. + * This method materialises both the DB-backed magic fields and the declared public + * fields into a single flat stdClass that set_data() can work with. + * + * @return \stdClass + */ + public function form_data(): \stdClass { + $data = new \stdClass(); + // DB-backed fields — use named accessors. + $data->id = $this->id(); + $data->surveyid = $this->surveyid(); + $data->name = $this->name(); + $data->typeid = $this->typeid(); + $data->length = $this->length(); + $data->precise = $this->precise(); + $data->position = $this->position(); + $data->content = $this->content(); + $data->required = $this->record ? $this->record->get('required') : null; + $data->deleted = $this->deleted(); + $data->extradata = $this->extradata(); + // Declared public properties used by the edit-question form. + $data->qid = $this->qid; + $data->sid = $this->sid; + $data->choices = $this->choices; + $data->allchoices = $this->allchoices; + $data->dependquestion = $this->dependquestion; + $data->dependchoice = $this->dependchoice; + $data->dependquestionsand = $this->dependquestionsand; + $data->dependlogicand = $this->dependlogicand; + $data->dependquestionsor = $this->dependquestionsor; + $data->dependlogicor = $this->dependlogicor; + return $data; + } + /** * Short name for this question type - no spaces, etc.. * @return string @@ -208,14 +520,14 @@ abstract public function helpname(); * @return mixed */ public static function question_builder($qtype, $qdata = null, $context = null) { - $qclassname = '\\mod_questionnaire\\question\\' . self::qtypename($qtype); + $qclassname = '\\mod_questionnaire\\local\\question\\' . self::qtypename($qtype); $qid = 0; if (!empty($qdata) && is_array($qdata)) { $qdata = (object)$qdata; } else if (!empty($qdata) && is_int($qdata)) { $qid = $qdata; } - return new $qclassname($qid, $qdata, $context, ['type_id' => $qtype]); + return new $qclassname($qid, $qdata, $context, ['typeid' => $qtype]); } /** @@ -224,11 +536,7 @@ public static function question_builder($qtype, $qdata = null, $context = null) * @return string */ public static function qtypename($qtype) { - if (array_key_exists($qtype, self::$qtypenames)) { - return self::$qtypenames[$qtype]; - } else { - return(''); - } + return question_type::qtypename((int)$qtype); } /** @@ -236,7 +544,7 @@ public static function qtypename($qtype) { * @return array */ public static function qtypenames() { - return self::$qtypenames; + return question_type::qtypenames(); } /** @@ -247,16 +555,37 @@ public function has_choices() { return false; } + /** + * Return all question type IDs whose questions have choice records. + * + * Queries the questionnaire_question_type table and caches the result for the + * duration of the request. + * + * @return int[] + */ + public static function typeids_with_choices(): array { + static $cache = null; + if ($cache !== null) { + return $cache; + } + $records = question_type_record::get_records(['haschoices' => 'y']); + $cache = []; + foreach ($records as $record) { + $cache[] = (int)$record->get('typeid'); + } + return $cache; + } + /** * Load any choices into the object. * @throws \dml_exception */ private function get_choices() { - global $DB; - - if ($choices = $DB->get_records('questionnaire_quest_choice', ['question_id' => $this->id], 'id ASC')) { - foreach ($choices as $choice) { - $this->choices[$choice->id] = \mod_questionnaire\question\choice::create_from_data($choice); + $choicerecs = \mod_questionnaire\local\db\choice_record::get_for_question($this->id()); + if ($choicerecs) { + foreach ($choicerecs as $choicerec) { + $this->choices[$choicerec->get('id')] = + \mod_questionnaire\local\question\choice::create_from_data($choicerec->to_record()); } } else { $this->choices = []; @@ -268,7 +597,7 @@ private function get_choices() { * @return bool */ public function required() { - return ($this->required == 'y'); + return $this->record ? $this->record->get('required') == 'y' : false; } /** @@ -291,24 +620,74 @@ public function allows_dependents() { * Load any dependencies. */ private function get_dependencies() { - global $DB; - $this->dependencies = []; - if ( - $dependencies = $DB->get_records( - 'questionnaire_dependency', - ['questionid' => $this->id, 'surveyid' => $this->surveyid], - 'id ASC' - ) - ) { - foreach ($dependencies as $dependency) { - $this->dependencies[$dependency->id] = new \stdClass(); - $this->dependencies[$dependency->id]->dependquestionid = $dependency->dependquestionid; - $this->dependencies[$dependency->id]->dependchoiceid = $dependency->dependchoiceid; - $this->dependencies[$dependency->id]->dependlogic = $dependency->dependlogic; - $this->dependencies[$dependency->id]->dependandor = $dependency->dependandor; + $deprecs = \mod_questionnaire\local\db\dependency_record::get_records( + ['questionid' => $this->id(), 'surveyid' => $this->surveyid()] + ); + foreach ($deprecs as $deprec) { + $did = $deprec->get('id'); + $this->dependencies[$did] = new \stdClass(); + $this->dependencies[$did]->dependquestionid = $deprec->get('dependquestionid'); + $this->dependencies[$did]->dependchoiceid = $deprec->get('dependchoiceid'); + $this->dependencies[$did]->dependlogic = $deprec->get('dependlogic'); + $this->dependencies[$did]->dependandor = $deprec->get('dependandor'); + } + } + + /** + * Parse a choice content string and return its components. + * + * Examines the content of a possible answer from radio button, check box, or rate question + * and returns an object with the following properties: + * - text: displayable text component + * - image: full img tag if an image was embedded, or empty string + * - modname: named-modality prefix (before '::'), or empty string + * - title: image title/alt text, or empty string + * + * @param string $content + * @return stdClass + */ + public static function parse_choice_content(string $content): stdClass { + $contents = new stdClass(); + $contents->text = ''; + $contents->image = ''; + $contents->modname = ''; + $contents->title = ''; + // Has image. + if (preg_match('/(image = $matches[0]; + $imageurl = $matches[3]; + // Image has a title or alt text: use one of them. + if ( + preg_match('/(title=.)([^"]{1,})/', $content, $matches) || + preg_match('/(alt=.)([^"]{1,})/', $content, $matches) + ) { + $contents->title = $matches[2]; + } else { + // Image has no title nor alt text: use its filename (without the extension). + preg_match("/.*\/(.*)\..*$/", $imageurl, $matches); + $contents->title = $matches[1]; + } + // Content has text or named modality plus an image. + if (preg_match('/(.*)(text = $content; + if ($pos = strpos($content, '::')) { + $contents->text = substr($content, $pos + 2); + $contents->modname = substr($content, 0, $pos); + } + return $contents; } /** @@ -320,7 +699,7 @@ protected function get_dependency_options() { $options = []; if ($this->allows_dependents() && $this->has_choices()) { foreach ($this->choices as $key => $choice) { - $contents = questionnaire_choice_values($choice->content); + $contents = self::parse_choice_content($choice->content); if (!empty($contents->modname)) { $choice->content = $contents->modname; } else if (!empty($contents->title)) { // Must be an image; use its title for the dropdown list. @@ -328,7 +707,7 @@ protected function get_dependency_options() { } else { $choice->content = format_string($contents->text); } - $options[$this->id . ',' . $key] = $this->name . '->' . $choice->content; + $options[$this->id() . ',' . $key] = $this->name() . '->' . $choice->content; } } return $options; @@ -414,7 +793,7 @@ public function response_has_choice($rid, $choiceid) { $choiceval = $this->responsetype->transform_choiceid($choiceid); return $DB->record_exists( $this->response_table(), - ['response_id' => $rid, 'question_id' => $this->id, 'choice_id' => $choiceval] + ['responseid' => $rid, 'questionid' => $this->id(), 'choiceid' => $choiceval] ); } @@ -426,7 +805,7 @@ public function response_has_choice($rid, $choiceid) { public function insert_response($responsedata) { if ( isset($this->responsetype) && is_object($this->responsetype) && - is_subclass_of($this->responsetype, '\\mod_questionnaire\\responsetype\\responsetype') + is_subclass_of($this->responsetype, '\\mod_questionnaire\\local\\response\\responsetype') ) { return $this->responsetype->insert_response($responsedata); } else { @@ -442,7 +821,7 @@ public function insert_response($responsedata) { public function get_results($rids = false) { if ( isset($this->responsetype) && is_object($this->responsetype) && - is_subclass_of($this->responsetype, '\\mod_questionnaire\\responsetype\\responsetype') + is_subclass_of($this->responsetype, '\\mod_questionnaire\\local\\response\\responsetype') ) { return $this->responsetype->get_results($rids); } else { @@ -460,7 +839,7 @@ public function get_results($rids = false) { public function display_results($rids = false, $sort = '', $anonymous = false) { if ( isset($this->responsetype) && is_object($this->responsetype) && - is_subclass_of($this->responsetype, '\\mod_questionnaire\\responsetype\\responsetype') + is_subclass_of($this->responsetype, '\\mod_questionnaire\\local\\response\\responsetype') ) { return $this->responsetype->display_results($rids, $sort, $anonymous); } else { @@ -549,7 +928,7 @@ public function is_numbered() { * @return bool */ public function valid_feedback() { - if ($this->supports_feedback() && $this->has_choices() && $this->required() && !empty($this->name)) { + if ($this->supports_feedback() && $this->has_choices() && $this->required() && !empty($this->name())) { foreach ($this->choices as $choice) { if ($choice->value != null) { return true; @@ -567,7 +946,7 @@ public function valid_feedback() { public function get_feedback_scores(array $rids) { if ( $this->valid_feedback() && isset($this->responsetype) && is_object($this->responsetype) && - is_subclass_of($this->responsetype, '\\mod_questionnaire\\responsetype\\responsetype') + is_subclass_of($this->responsetype, '\\mod_questionnaire\\local\\response\\responsetype') ) { return $this->responsetype->get_feedback_scores($rids); } else { @@ -601,10 +980,10 @@ public function get_feedback_maxscore() { * @return bool */ public function response_complete($responsedata) { - if (is_a($responsedata, 'mod_questionnaire\responsetype\response\response')) { + if (is_a($responsedata, 'mod_questionnaire\local\response\response')) { // If $responsedata is a response object, look through the answers. - if (isset($responsedata->answers[$this->id]) && !empty($responsedata->answers[$this->id])) { - $answer = $responsedata->answers[$this->id][0]; + if (isset($responsedata->answers[$this->id()]) && !empty($responsedata->answers[$this->id()])) { + $answer = $responsedata->answers[$this->id()][0]; if ( !empty($answer->choiceid) && isset($this->choices[$answer->choiceid]) && $this->choices[$answer->choiceid]->is_other_choice() @@ -618,9 +997,9 @@ public function response_complete($responsedata) { } } else { // If $responsedata is webform data, check that its not empty. - $answered = isset($responsedata->{'q' . $this->id}) && ($responsedata->{'q' . $this->id} != ''); + $answered = isset($responsedata->{'q' . $this->id()}) && ($responsedata->{'q' . $this->id()} != ''); } - return !($this->required() && (empty($this->deleted)) && !$answered); + return !($this->required() && (empty($this->deleted())) && !$answered); } /** @@ -638,33 +1017,10 @@ public function response_valid($responsedata) { * @param bool $updatechoices True if choices should also be updated. */ public function update($questionrecord = null, $updatechoices = true) { - global $DB; - - if ($questionrecord === null) { - $questionrecord = new \stdClass(); - $questionrecord->id = $this->id; - $questionrecord->surveyid = $this->surveyid; - $questionrecord->name = $this->name; - $questionrecord->type_id = $this->type_id; - $questionrecord->result_id = $this->result_id; - $questionrecord->length = $this->length; - $questionrecord->precise = $this->precise; - $questionrecord->position = $this->position; - $questionrecord->content = $this->content; - $questionrecord->required = $this->required; - $questionrecord->deleted = $this->deleted; - $questionrecord->extradata = $this->extradata; - $questionrecord->dependquestion = $this->dependquestion; - $questionrecord->dependchoice = $this->dependchoice; - } else { - // Make sure the "id" field is this question's. - if (isset($this->qid) && ($this->qid > 0)) { - $questionrecord->id = $this->qid; - } else { - $questionrecord->id = $this->id; - } + if ($questionrecord !== null) { + $this->record->from_record($questionrecord); } - $DB->update_record('questionnaire_question', $questionrecord); + $this->record->update(); if ($updatechoices && $this->has_choices()) { $this->update_choices(); @@ -678,32 +1034,29 @@ public function update($questionrecord = null, $updatechoices = true) { * @param bool|null $calcposition Whether or not to calculate the next available position in the survey. */ public function add($questionrecord, ?array $choicerecords = null, ?bool $calcposition = true) { - global $DB; - // Create new question. if ($calcposition) { // Set the position to the end. - $sql = 'SELECT MAX(position) as maxpos ' . - 'FROM {questionnaire_question} ' . - 'WHERE surveyid = ? AND deleted IS NULL'; - $params = ['surveyid' => $questionrecord->surveyid]; - if ($record = $DB->get_record_sql($sql, $params)) { - $questionrecord->position = $record->maxpos + 1; - } else { - $questionrecord->position = 1; - } + $questionrecord->position = \mod_questionnaire\local\db\question_record::max_active_position_for_survey( + (int) $questionrecord->surveyid + ) + 1; } // Make sure we add all necessary data. - if (!isset($questionrecord->type_id) || empty($questionrecord->type_id)) { - $questionrecord->type_id = $this->type_id; + if (!isset($questionrecord->typeid) || empty($questionrecord->typeid)) { + $questionrecord->typeid = $this->typeid(); } - $this->qid = $DB->insert_record('questionnaire_question', $questionrecord); + $questionpersistent = new \mod_questionnaire\local\db\question_record(0, $questionrecord); + $questionpersistent->save(); + // Adopt the freshly-saved persistent as this question's backing record so + // subsequent mutations and accessors are in sync without an extra DB read. + $this->record = $questionpersistent; + $this->qid = $questionpersistent->get('id'); if ($this->has_choices() && !empty($choicerecords)) { foreach ($choicerecords as $choicerecord) { - $choicerecord->question_id = $this->qid; + $choicerecord->questionid = $this->qid; $this->add_choice($choicerecord); } } @@ -720,12 +1073,12 @@ public function update_choices() { if (isset($this->qid) && ($this->qid > 0)) { $qid = $this->qid; } else { - $qid = $this->id; + $qid = $this->id(); } foreach ($this->choices as $key => $choice) { $choicerecord = new \stdClass(); $choicerecord->id = $key; - $choicerecord->question_id = $qid; + $choicerecord->questionid = $qid; $choicerecord->content = $choice->content; $choicerecord->value = $choice->value; $retvalue &= $this->update_choice($choicerecord); @@ -740,8 +1093,15 @@ public function update_choices() { * @return bool */ public function update_choice($choicerecord) { - global $DB; - return $DB->update_record('questionnaire_quest_choice', $choicerecord); + $choicepersistent = \mod_questionnaire\local\db\choice_record::get_record(['id' => $choicerecord->id]); + if (!$choicepersistent) { + return false; + } + $choicepersistent->set('questionid', $choicerecord->questionid); + $choicepersistent->set('content', $choicerecord->content); + $choicepersistent->set('value', $choicerecord->value ?? null); + $choicepersistent->save(); + return true; } /** @@ -750,16 +1110,16 @@ public function update_choice($choicerecord) { * @return bool */ public function add_choice($choicerecord) { - global $DB; - $retvalue = true; - if ($cid = $DB->insert_record('questionnaire_quest_choice', $choicerecord)) { + $choicepersistent = new \mod_questionnaire\local\db\choice_record(0, $choicerecord); + $choicepersistent->save(); + $cid = $choicepersistent->get('id'); + if ($cid) { $this->choices[$cid] = new \stdClass(); $this->choices[$cid]->content = $choicerecord->content; - $this->choices[$cid]->value = isset($choicerecord->value) ? $choicerecord->value : null; - } else { - $retvalue = false; + $this->choices[$cid]->value = $choicerecord->value ?? null; + return true; } - return $retvalue; + return false; } /** @@ -773,7 +1133,7 @@ public function delete_choice($choice) { } else { $cid = $choice->id; } - if (\mod_questionnaire\question\choice::delete_from_db_by_id($cid)) { + if (\mod_questionnaire\local\question\choice::delete_from_db_by_id($cid)) { unset($this->choices[$cid]); } else { $retvalue = false; @@ -787,8 +1147,8 @@ public function delete_choice($choice) { * @return bool */ public function insert_extradata($extradata) { - global $DB; - return $DB->set_field('questionnaire_question', 'extradata', $extradata, ['id' => $this->id]); + $this->record->set('extradata', $extradata); + return $this->record->update(); } /** @@ -797,8 +1157,18 @@ public function insert_extradata($extradata) { * @return bool */ public function update_dependency($dependencyrecord) { - global $DB; - return $DB->update_record('questionnaire_dependency', $dependencyrecord); + $deppersistent = \mod_questionnaire\local\db\dependency_record::get_record(['id' => $dependencyrecord->id]); + if (!$deppersistent) { + return false; + } + $deppersistent->set('questionid', $dependencyrecord->questionid); + $deppersistent->set('surveyid', $dependencyrecord->surveyid); + $deppersistent->set('dependquestionid', $dependencyrecord->dependquestionid); + $deppersistent->set('dependchoiceid', $dependencyrecord->dependchoiceid); + $deppersistent->set('dependlogic', $dependencyrecord->dependlogic); + $deppersistent->set('dependandor', $dependencyrecord->dependandor); + $deppersistent->save(); + return true; } /** @@ -807,19 +1177,18 @@ public function update_dependency($dependencyrecord) { * @return bool */ public function add_dependency($dependencyrecord) { - global $DB; - - $retvalue = true; - if ($did = $DB->insert_record('questionnaire_dependency', $dependencyrecord)) { + $deppersistent = new \mod_questionnaire\local\db\dependency_record(0, $dependencyrecord); + $deppersistent->save(); + $did = $deppersistent->get('id'); + if ($did) { $this->dependencies[$did] = new \stdClass(); $this->dependencies[$did]->dependquestionid = $dependencyrecord->dependquestionid; $this->dependencies[$did]->dependchoiceid = $dependencyrecord->dependchoiceid; $this->dependencies[$did]->dependlogic = $dependencyrecord->dependlogic; $this->dependencies[$did]->dependandor = $dependencyrecord->dependandor; - } else { - $retvalue = false; + return true; } - return $retvalue; + return false; } /** @@ -827,20 +1196,18 @@ public function add_dependency($dependencyrecord) { * @param int|\stdClass $dependency Either the integer id of the dependency, or the dependency record. */ public function delete_dependency($dependency) { - global $DB; - - $retvalue = true; if (is_int($dependency)) { $did = $dependency; } else { $did = $dependency->id; } - if ($DB->delete_records('questionnaire_dependency', ['id' => $did])) { + $deppersistent = \mod_questionnaire\local\db\dependency_record::get_record(['id' => $did]); + if ($deppersistent) { + $deppersistent->delete(); unset($this->dependencies[$did]); - } else { - $retvalue = false; + return true; } - return $retvalue; + return false; } /** @@ -848,16 +1215,9 @@ public function delete_dependency($dependency) { * @param bool $required Whether question should be required or not. */ public function set_required($required) { - global $DB; $rval = $required ? 'y' : 'n'; - // Need to fix this messed-up qid/id issue. - if (isset($this->qid) && ($this->qid > 0)) { - $qid = $this->qid; - } else { - $qid = $this->id; - } - $this->required = $rval; - return $DB->set_field('questionnaire_question', 'required', $rval, ['id' => $qid]); + $this->record->set('required', $rval); + return $this->record->update(); } /** @@ -897,10 +1257,10 @@ public function response_template() { * @param bool $pdf * @return bool|string */ - public function results_template($pdf = false) { + public function results_template(bool $pdf = false) { if ( isset($this->responsetype) && is_object($this->responsetype) && - is_subclass_of($this->responsetype, '\\mod_questionnaire\\responsetype\\responsetype') + is_subclass_of($this->responsetype, '\\mod_questionnaire\\local\\response\\responsetype') ) { return $this->responsetype->results_template($pdf); } else { @@ -910,26 +1270,39 @@ public function results_template($pdf = false) { /** * Get the output for question renderers / templates. - * @param \mod_questionnaire\responsetype\response\response $response - * @param boolean $blankquestionnaire + * + * @param \mod_questionnaire\local\response\response|\stdClass $response The response object or form data. + * @param bool $blankquestionnaire Whether the questionnaire is blank. * @param array $dependants Array of all questions/choices depending on this question. - * @param int $qnum - * @return \stdClass - */ - public function question_output($response, $blankquestionnaire, $dependants = [], $qnum = '') { - $pagetags = $this->questionstart_survey_display($qnum, $response); + * @param int|string $qnum The question number. + * @param \questionnaire|null $questionnaire The parent questionnaire object. + * @param bool $individualresponse True when rendering a single individual response (mybyresponse / individualresp). + * @return \stdClass The output object for rendering. + */ + public function question_output( + $response, + $blankquestionnaire, + $dependants = [], + $qnum = '', + $questionnaire = null, + bool $individualresponse = false + ): stdClass { + $this->questionnaire = $questionnaire; + $pagetags = $this->questionstart_survey_display($qnum, $response, $questionnaire, $individualresponse); $pagetags->qformelement = $this->question_survey_display($response, $dependants, $blankquestionnaire); return $pagetags; } /** * Get the output for question renderers / templates. - * @param \mod_questionnaire\responsetype\response\response $response + * @param \mod_questionnaire\local\response\response $response * @param string $qnum + * @param \questionnaire|null $questionnaire The parent questionnaire object. + * @param bool $individualresponse True when rendering a single individual response (mybyresponse / individualresp). * @return \stdClass */ - public function response_output($response, $qnum = '') { - $pagetags = $this->questionstart_survey_display($qnum, $response); + public function response_output($response, $qnum = '', $questionnaire = null, bool $individualresponse = false) { + $pagetags = $this->questionstart_survey_display($qnum, $response, $questionnaire, $individualresponse); $pagetags->qformelement = $this->response_survey_display($response); return $pagetags; } @@ -937,28 +1310,34 @@ public function response_output($response, $qnum = '') { /** * Get the output for the start of the questions in a survey. * @param int $qnum - * @param \mod_questionnaire\responsetype\response\response $response + * @param response $response + * @param questionnaire|null $questionnaire The parent questionnaire object. + * @param bool $individualresponse True when rendering a single individual response (mybyresponse / individualresp). * @return \stdClass */ - public function questionstart_survey_display($qnum, $response = null) { - global $OUTPUT, $SESSION, $questionnaire, $PAGE; + public function questionstart_survey_display( + $qnum, + $response = null, + $questionnaire = null, + bool $individualresponse = false + ): stdClass { + global $OUTPUT, $PAGE; $pagetags = new \stdClass(); - $currenttab = $SESSION->questionnaire->current_tab; $pagetype = $PAGE->pagetype; $skippedclass = ''; // If no questions autonumbering. $nonumbering = false; - if (!$questionnaire->questions_autonumbered()) { + if ($questionnaire !== null && !$questionnaire->questions_autonumbered()) { $qnum = ''; $nonumbering = true; } // For now, check what the response type is until we've got it all refactored. - if ($response instanceof \mod_questionnaire\responsetype\response\response) { - $skippedquestion = !isset($response->answers[$this->id]); + if ($response instanceof \mod_questionnaire\local\response\response) { + $skippedquestion = !isset($response->answers[$this->id()]); } else { - $skippedquestion = !empty($response) && !isset($response->{'q' . $this->id}); + $skippedquestion = !empty($response) && !isset($response->{'q' . $this->id()}); } // If we are on report page and this questionnaire has dependquestions and this question was skipped. @@ -974,20 +1353,23 @@ public function questionstart_survey_display($qnum, $response = null) { // also hide answers to questions that have not been answered. $displayclass = 'qn-container'; if ( - $pagetype == 'mod-questionnaire-preview' || - ($nonumbering && ($currenttab == 'mybyresponse' || $currenttab == 'individualresp')) + $questionnaire !== null && + ( + $pagetype == 'mod-questionnaire-preview' || + ($nonumbering && $individualresponse) + ) ) { // This needs to be done to ensure all dependency data is loaded. // TODO - Perhaps this should be a function called by the questionnaire after it loads all questions? $questionnaire->load_parents($this); - // Want this to come from the renderer, meaning we need $questionnaire. - $pagetags->dependencylist = $questionnaire->renderer->get_dependency_html($this->id, $this->dependencies); + $pagetags->dependencylist = $PAGE->get_renderer('mod_questionnaire') + ->get_dependency_html($this->id(), $this->dependencies); } - $pagetags->fieldset = (object)['id' => $this->id, 'class' => $displayclass]; + $pagetags->fieldset = (object)['id' => $this->id(), 'class' => $displayclass]; // Do not display the info box for the label question type. - if ($this->type_id != QUESSECTIONTEXT) { + if ($this->typeid() != QUESSECTIONTEXT) { if (!$nonumbering) { $pagetags->qnum = $qnum; } @@ -1002,24 +1384,24 @@ public function questionstart_survey_display($qnum, $response = null) { $pagetags->required = $required; // Need to replace this with better renderer / template? } // If question text is "empty", i.e. 2 non-breaking spaces were inserted, empty it. - if ($this->content == '

  

') { - $this->content = ''; + if ($this->content() == '

  

') { + $this->set_content(''); } $pagetags->skippedclass = $skippedclass; - if ($this->type_id == QUESNUMERIC || $this->type_id == QUESTEXT || $this->type_id == QUESSLIDER) { - $pagetags->label = (object)['for' => self::qtypename($this->type_id) . $this->id]; - } else if ($this->type_id == QUESDROP) { - $pagetags->label = (object)['for' => self::qtypename($this->type_id) . $this->name]; - } else if ($this->type_id == QUESESSAY) { - $pagetags->label = (object)['for' => 'q' . $this->id]; + if ($this->typeid() == QUESNUMERIC || $this->typeid() == QUESTEXT || $this->typeid() == QUESSLIDER) { + $pagetags->label = (object)['for' => self::qtypename($this->typeid()) . $this->id()]; + } else if ($this->typeid() == QUESDROP) { + $pagetags->label = (object)['for' => self::qtypename($this->typeid()) . $this->name()]; + } else if ($this->typeid() == QUESESSAY) { + $pagetags->label = (object)['for' => 'q' . $this->id()]; } $content = file_rewrite_pluginfile_urls( - $this->content, + $this->content(), 'pluginfile.php', $this->context->id, 'mod_questionnaire', 'question', - $this->id + $this->id() ); $options = ['noclean' => true, 'para' => false, 'filter' => true, 'context' => $this->context, 'overflowdiv' => true]; $pagetags->qcontent = format_text($content, FORMAT_HTML, $options); @@ -1038,10 +1420,10 @@ public function questionstart_survey_display($qnum, $response = null) { * Override this, or any of the internal methods, to provide specific form data for editing the question type. * The structure of the elements here is the default layout for the question form. * @param edit_question_form $form The main moodleform object. - * @param questionnaire $questionnaire The questionnaire being edited. + * @param \mod_questionnaire\questionnaire $questionnaire The questionnaire being edited. * @return bool */ - public function edit_form(edit_question_form $form, questionnaire $questionnaire) { + public function edit_form(edit_question_form $form, \mod_questionnaire\questionnaire $questionnaire) { $mform =& $form->_form; $this->form_header($mform); $this->form_name($mform); @@ -1058,8 +1440,8 @@ public function edit_form(edit_question_form $form, questionnaire $questionnaire $this->form_extradata($mform); // Added for advanced dependencies, parameter $editformobject is needed to use repeat_elements. - if ($questionnaire->navigate > 0) { - $this->form_dependencies($form, $questionnaire->questions); + if ($questionnaire->navigate() > 0) { + $this->form_dependencies($form, $questionnaire->questions()); } // Exclude the save/cancel buttons from any collapsing sections. @@ -1072,8 +1454,8 @@ public function edit_form(edit_question_form $form, questionnaire $questionnaire $mform->setType('qid', PARAM_INT); $mform->addElement('hidden', 'sid', 0); $mform->setType('sid', PARAM_INT); - $mform->addElement('hidden', 'type_id', $this->type_id); - $mform->setType('type_id', PARAM_INT); + $mform->addElement('hidden', 'typeid', $this->typeid()); + $mform->setType('typeid', PARAM_INT); $mform->addElement('hidden', 'action', 'question'); $mform->setType('action', PARAM_ALPHA); @@ -1096,9 +1478,9 @@ public function edit_form(edit_question_form $form, questionnaire $questionnaire protected function form_header(\MoodleQuickForm $mform, $helpname = '') { // Display different messages for new question creation and existing question modification. if (isset($this->qid) && !empty($this->qid)) { - $header = get_string('editquestion', 'questionnaire', questionnaire_get_type($this->type_id)); + $header = get_string('editquestion', 'questionnaire', question_type::display_name($this->typeid())); } else { - $header = get_string('addnewquestion', 'questionnaire', questionnaire_get_type($this->type_id)); + $header = get_string('addnewquestion', 'questionnaire', question_type::display_name($this->typeid())); } if (empty($helpname)) { $helpname = $this->helpname(); @@ -1166,15 +1548,15 @@ protected function form_precise(\MoodleQuickForm $mform, $helpname = '') { protected function form_dependencies($form, $questions) { // Create a new area for multiple dependencies. $mform = $form->_form; - $position = ($this->position !== 0) ? $this->position : count($questions) + 1; + $position = ($this->position() !== 0) ? $this->position() : count($questions) + 1; $dependencies = []; $dependencies[''][0] = get_string('choosedots'); foreach ($questions as $question) { if ( - ($question->position < $position) && !empty($question->name) && + ($question->position() < $position) && !empty($question->name()) && !empty($dependopts = $question->get_dependency_options()) ) { - $dependencies[$question->name] = $dependopts; + $dependencies[$question->name()] = $dependopts; } } @@ -1425,11 +1807,9 @@ public static function form_precise_text(\MoodleQuickForm $mform, $helpname = '' /** * Create and update question data from the forms. * @param \stdClass $formdata - * @param questionnaire $questionnaire + * @param \mod_questionnaire\questionnaire $questionnaire */ public function form_update($formdata, $questionnaire) { - global $DB; - $this->form_preprocess_data($formdata); if (!empty($formdata->qid)) { // Update existing question. @@ -1439,7 +1819,7 @@ public function form_update($formdata, $questionnaire) { $formdata->content = $formdata->content['text']; $formdata->content = file_save_draft_area_files( $formdata->itemid, - $questionnaire->context->id, + $questionnaire->context()->id, 'mod_questionnaire', 'question', $formdata->qid, @@ -1447,7 +1827,7 @@ public function form_update($formdata, $questionnaire) { $formdata->content ); - $fields = ['name', 'type_id', 'length', 'precise', 'required', 'content', 'extradata']; + $fields = ['name', 'typeid', 'length', 'precise', 'required', 'content', 'extradata']; $questionrecord = new \stdClass(); $questionrecord->id = $formdata->qid; foreach ($fields as $f) { @@ -1459,13 +1839,13 @@ public function form_update($formdata, $questionnaire) { $this->update($questionrecord, false); if ($questionnaire->has_dependencies()) { - questionnaire_check_page_breaks($questionnaire); + $questionnaire->survey()->check_page_breaks(); } } else { // Create new question: // Need to update any image content after the question is created, so create then update the content. $formdata->surveyid = $formdata->sid; - $fields = ['surveyid', 'name', 'type_id', 'length', 'precise', 'required', 'position', 'extradata']; + $fields = ['surveyid', 'name', 'typeid', 'length', 'precise', 'required', 'position', 'extradata']; $questionrecord = new \stdClass(); foreach ($fields as $f) { if (isset($formdata->$f)) { @@ -1482,14 +1862,15 @@ public function form_update($formdata, $questionnaire) { $formdata->content = $formdata->content['text']; $content = file_save_draft_area_files( $formdata->itemid, - $questionnaire->context->id, + $questionnaire->context()->id, 'mod_questionnaire', 'question', $this->qid, ['subdirs' => true], $formdata->content ); - $DB->set_field('questionnaire_question', 'content', $content, ['id' => $this->qid]); + $this->record->set('content', $content); + $this->record->update(); } if ($this->has_choices()) { @@ -1511,7 +1892,7 @@ public function form_update($formdata, $questionnaire) { if ($newchoices[$nidx] != $echoice->content) { $choicerecord = new \stdClass(); $choicerecord->id = $ekey; - $choicerecord->question_id = $this->qid; + $choicerecord->questionid = $this->qid; $choicerecord->content = trim($newchoices[$nidx]); $r = preg_match_all("/^(\d{1,2})(=.*)$/", $newchoices[$nidx], $matches); // This choice has been attributed a "score value" OR this is a rate question type. @@ -1532,7 +1913,7 @@ public function form_update($formdata, $questionnaire) { while ($nidx < $newcount) { // New choices. $choicerecord = new \stdClass(); - $choicerecord->question_id = $this->qid; + $choicerecord->questionid = $this->qid; $choicerecord->content = trim($newchoices[$nidx]); $r = preg_match_all("/^(\d{1,2})(=.*)$/", $choicerecord->content, $matches); // This choice has been attributed a "score value" OR this is a rate question type. @@ -1582,7 +1963,7 @@ public function form_update($formdata, $questionnaire) { $dependencyrecord = new \stdClass(); $dependencyrecord->id = $ekey; $dependencyrecord->questionid = $this->qid; - $dependencyrecord->surveyid = $this->surveyid; + $dependencyrecord->surveyid = $this->surveyid(); $dependencyrecord->dependquestionid = $formdata->dependquestion[$nidx]; $dependencyrecord->dependchoiceid = $formdata->dependchoice[$nidx]; $dependencyrecord->dependlogic = $formdata->dependlogic_cleaned[$nidx]; @@ -1699,35 +2080,35 @@ public function mobile_question_display($qnum, $autonum = false) { $options = ['noclean' => true, 'para' => false, 'filter' => true, 'context' => $this->context, 'overflowdiv' => true]; $mobiledata = (object)[ - 'id' => $this->id, - 'name' => $this->name, - 'type_id' => $this->type_id, - 'length' => $this->length, + 'id' => $this->id(), + 'name' => $this->name(), + 'typeid' => $this->typeid(), + 'length' => $this->length(), 'content' => format_text( file_rewrite_pluginfile_urls( - $this->content, + $this->content(), 'pluginfile.php', $this->context->id, 'mod_questionnaire', 'question', - $this->id + $this->id() ), FORMAT_HTML, $options ), - 'content_stripped' => strip_tags($this->content), - 'required' => ($this->required == 'y') ? 1 : 0, - 'deleted' => $this->deleted, - 'response_table' => $this->responsetable, + 'content_stripped' => strip_tags($this->content()), + 'required' => $this->required() ? 1 : 0, + 'deleted' => $this->deleted(), + 'response_table' => $this->questiontype ? $this->questiontype->responsetable : null, 'fieldkey' => $this->mobile_fieldkey(), - 'precise' => $this->precise, + 'precise' => $this->precise(), 'qnum' => $qnum, - 'errormessage' => get_string('required') . ': ' . $this->name, + 'errormessage' => get_string('required') . ': ' . $this->name(), ]; $mobiledata->choices = $this->mobile_question_choices_display(); if ($this->mobile_question_extradata_display()) { - $mobiledata->extradata = json_decode($this->extradata); + $mobiledata->extradata = json_decode($this->extradata()); } if ($autonum) { $mobiledata->content = $qnum . '. ' . $mobiledata->content; @@ -1747,7 +2128,7 @@ public function mobile_question_choices_display() { if ($this->has_choices()) { foreach ($this->choices as $choice) { $choices[$cnum] = clone($choice); - $contents = questionnaire_choice_values($choice->content); + $contents = self::parse_choice_content($choice->content); $choices[$cnum]->content = format_text($contents->text, FORMAT_HTML, ['noclean' => true]) . $contents->image; $cnum++; } @@ -1765,7 +2146,7 @@ public function mobile_fieldkey($choiceid = 0) { if ($choiceid !== 0) { $choicefield = '_' . $choiceid; } - return 'response_' . $this->type_id . '_' . $this->id . $choicefield; + return 'response_' . $this->typeid() . '_' . $this->id() . $choicefield; } /** @@ -1775,8 +2156,8 @@ public function mobile_fieldkey($choiceid = 0) { */ public function get_mobile_response_data($response) { $resultdata = []; - if (isset($response->answers[$this->id][0])) { - $resultdata[$this->mobile_fieldkey()] = $response->answers[$this->id][0]->value; + if (isset($response->answers[$this->id()][0])) { + $resultdata[$this->mobile_fieldkey()] = $response->answers[$this->id()][0]->value; } else { $resultdata[$this->mobile_fieldkey()] = false; } diff --git a/classes/question/radio.php b/classes/local/question/radio.php similarity index 88% rename from classes/question/radio.php rename to classes/local/question/radio.php index ea86e0e0a..3a3e37d50 100644 --- a/classes/question/radio.php +++ b/classes/local/question/radio.php @@ -14,7 +14,7 @@ // You should have received a copy of the GNU General Public License // along with Moodle. If not, see . -namespace mod_questionnaire\question; +namespace mod_questionnaire\local\question; /** * This file contains the parent class for radio question types. @@ -30,7 +30,7 @@ class radio extends question { * @return object The response object based off of questionnaire_response_base. */ protected function responseclass() { - return '\\mod_questionnaire\\responsetype\\single'; + return '\\mod_questionnaire\\local\\response\\single'; } /** @@ -81,7 +81,7 @@ public function supports_feedback() { /** * Return the context tags for the check question template. - * @param \mod_questionnaire\responsetype\response\response $response + * @param \mod_questionnaire\local\response\response $response * @param array $dependants Array of all questions/choices depending on this question. * @param boolean $blankquestionnaire * @return object The check question context tags. @@ -92,7 +92,7 @@ protected function question_survey_display($response, $dependants = [], $blankqu global $idcounter; // To make sure all radio buttons have unique ids. // JR 20 NOV 2007. $otherempty = false; - $horizontal = $this->length; + $horizontal = $this->length(); $ischecked = false; $choicetags = new \stdClass(); @@ -107,10 +107,10 @@ protected function question_survey_display($response, $dependants = [], $blankqu if (!$choice->is_other_choice()) { // This is a normal radio button. $htmlid = 'auto-rb' . sprintf('%04d', ++$idcounter); - $radio->name = 'q' . $this->id; + $radio->name = 'q' . $this->id(); $radio->id = $htmlid; $radio->value = $id; - if (isset($response->answers[$this->id][$id])) { + if (isset($response->answers[$this->id()][$id])) { $radio->checked = true; $ischecked = true; } @@ -119,7 +119,7 @@ protected function question_survey_display($response, $dependants = [], $blankqu $radio->disabled = true; $value = ' (' . $choice->value . ') '; } - $contents = questionnaire_choice_values($choice->content); + $contents = question::parse_choice_content($choice->content); $radio->label = $value . format_text($contents->text, FORMAT_HTML, ['noclean' => true]) . $contents->image; if (!empty($this->qlegend)) { $radio->alabel = strip_tags("{$this->qlegend} {$radio->label}"); @@ -127,19 +127,19 @@ protected function question_survey_display($response, $dependants = [], $blankqu } else { // Radio button with associated !other text field. $othertext = $choice->other_choice_display(); $cname = choice::id_other_choice_name($id); - $odata = isset($response->answers[$this->id][$id]) ? $response->answers[$this->id][$id]->value : ''; + $odata = isset($response->answers[$this->id()][$id]) ? $response->answers[$this->id()][$id]->value : ''; $htmlid = 'auto-rb' . sprintf('%04d', ++$idcounter); - $radio->name = 'q' . $this->id; + $radio->name = 'q' . $this->id(); $radio->id = $htmlid; $radio->value = $id; - if (isset($response->answers[$this->id][$id]) || !empty($odata)) { + if (isset($response->answers[$this->id()][$id]) || !empty($odata)) { $radio->checked = true; $ischecked = true; } $otherempty = !empty($radio->checked) && empty($odata); $radio->label = format_text($othertext, FORMAT_HTML, ['noclean' => true]); - $radio->oname = 'q' . $this->id . choice::id_other_choice_name($id); + $radio->oname = 'q' . $this->id() . choice::id_other_choice_name($id); $radio->oid = $htmlid . '-other'; if (isset($odata)) { $radio->ovalue = format_string(stripslashes($odata)); @@ -161,7 +161,7 @@ protected function question_survey_display($response, $dependants = [], $blankqu $radio->horizontal = $horizontal; } - $radio->name = 'q' . $this->id; + $radio->name = 'q' . $this->id(); $radio->id = $htmlid; $radio->value = 0; @@ -186,7 +186,7 @@ protected function question_survey_display($response, $dependants = [], $blankqu /** * Return the context tags for the radio response template. - * @param \mod_questionnaire\responsetype\response\response $response + * @param \mod_questionnaire\local\response\response $response * @return object The radio question response context tags. */ protected function response_survey_display($response) { @@ -196,9 +196,9 @@ protected function response_survey_display($response) { $resptags->choices = []; $qdata = new \stdClass(); - $horizontal = $this->length; - if (isset($response->answers[$this->id])) { - $answer = reset($response->answers[$this->id]); + $horizontal = $this->length(); + if (isset($response->answers[$this->id()])) { + $answer = reset($response->answers[$this->id()]); $checked = $answer->choiceid; } else { $checked = null; @@ -209,7 +209,7 @@ protected function response_survey_display($response) { $chobj->horizontal = 1; } $chobj->name = $id . $uniquetag++; - $contents = questionnaire_choice_values($choice->content); + $contents = question::parse_choice_content($choice->content); $choice->content = $contents->text . $contents->image; if ($id == $checked) { $chobj->selected = 1; @@ -239,12 +239,12 @@ protected function response_survey_display($response) { */ public function response_complete($responsedata) { if ( - isset($responsedata->{'q' . $this->id}) && ($this->required()) && - (strpos($responsedata->{'q' . $this->id}, 'other_') !== false) + isset($responsedata->{'q' . $this->id()}) && ($this->required()) && + (strpos($responsedata->{'q' . $this->id()}, 'other_') !== false) ) { return ( trim( - $responsedata->{'q' . $this->id . '' . substr($responsedata->{'q' . $this->id}, 5)} + $responsedata->{'q' . $this->id() . '' . substr($responsedata->{'q' . $this->id()}, 5)} ) != false ); } else { @@ -320,8 +320,8 @@ public function mobile_question_choices_display() { */ public function get_mobile_response_data($response) { $resultdata = []; - if (isset($response->answers[$this->id])) { - foreach ($response->answers[$this->id] as $answer) { + if (isset($response->answers[$this->id()])) { + foreach ($response->answers[$this->id()] as $answer) { // Add a fieldkey for each choice. $resultdata[$this->mobile_fieldkey()] = $answer->choiceid; if ($this->choices[$answer->choiceid]->is_other_choice()) { diff --git a/classes/question/rate.php b/classes/local/question/rate.php similarity index 84% rename from classes/question/rate.php rename to classes/local/question/rate.php index d9ab3ced6..932307060 100644 --- a/classes/question/rate.php +++ b/classes/local/question/rate.php @@ -14,7 +14,10 @@ // You should have received a copy of the GNU General Public License // along with Moodle. If not, see . -namespace mod_questionnaire\question; +namespace mod_questionnaire\local\question; + +use mod_questionnaire\local\db\question_record; +use mod_questionnaire\local\db\response_rank_record; /** * This file contains the parent class for rate question types. @@ -42,7 +45,7 @@ class rate extends question { * @param array $params */ public function __construct($id = 0, $question = null, $context = null, $params = []) { - $this->length = 5; + $this->set_length(5); parent::__construct($id, $question, $context, $params); $this->add_nameddegrees_from_extradata(); } @@ -52,7 +55,7 @@ public function __construct($id = 0, $question = null, $context = null, $params * @return object The response object based off of questionnaire_response_base. */ protected function responseclass() { - return '\\mod_questionnaire\\responsetype\\rank'; + return '\\mod_questionnaire\\local\\response\\rank'; } /** @@ -127,7 +130,7 @@ public static function type_is_osgood_rate_scale($scaletype) { * @return bool */ public function normal_rate_scale() { - return self::type_is_normal_rate_scale($this->precise); + return self::type_is_normal_rate_scale($this->precise()); } /** @@ -135,7 +138,7 @@ public function normal_rate_scale() { * @return bool */ public function has_na_column() { - return self::type_is_na_column($this->precise); + return self::type_is_na_column($this->precise()); } /** @@ -143,7 +146,7 @@ public function has_na_column() { * @return bool */ public function no_duplicate_choices() { - return self::type_is_no_duplicate_choices($this->precise); + return self::type_is_no_duplicate_choices($this->precise()); } /** @@ -151,7 +154,7 @@ public function no_duplicate_choices() { * @return bool */ public function osgood_rate_scale() { - return self::type_is_osgood_rate_scale($this->precise); + return self::type_is_osgood_rate_scale($this->precise()); } /** @@ -165,7 +168,7 @@ public function supports_feedback() { * True if the question supports feedback and has valid settings for feedback. Override if the default logic is not enough. */ public function valid_feedback() { - return $this->supports_feedback() && $this->has_choices() && $this->required() && !empty($this->name) && + return $this->supports_feedback() && $this->has_choices() && $this->required() && !empty($this->name()) && ($this->normal_rate_scale() || $this->osgood_rate_scale()) && !empty($this->nameddegrees); } @@ -192,7 +195,7 @@ public function get_feedback_maxscore() { /** * Return the context tags for the check question template. - * @param \mod_questionnaire\responsetype\response\response $response + * @param \mod_questionnaire\local\response\response $response * @param string $descendantsdata * @param boolean $blankquestionnaire * @return object The check question context tags. @@ -203,14 +206,14 @@ public function get_feedback_maxscore() { protected function question_survey_display($response, $descendantsdata, $blankquestionnaire = false) { $choicetags = new \stdClass(); $choicetags->qelements = []; - $choicetags->qelements['caption'] = strip_tags($this->content); + $choicetags->qelements['caption'] = strip_tags($this->content()); $disabled = ''; if ($blankquestionnaire) { $disabled = ' disabled="disabled"'; } - if (!empty($data) && (!isset($data->{'q' . $this->id}) || !is_array($data->{'q' . $this->id}))) { - $data->{'q' . $this->id} = []; + if (!empty($data) && (!isset($data->{'q' . $this->id()}) || !is_array($data->{'q' . $this->id()}))) { + $data->{'q' . $this->id()} = []; } // Check if rate question has one line only to display full width columns of choices. @@ -226,7 +229,7 @@ protected function question_survey_display($response, $descendantsdata, $blankqu } if ($nameddegrees == 0) { // Determine if the choices have named values. - $contents = questionnaire_choice_values($content); + $contents = question::parse_choice_content($content); if ($contents->modname) { $choice->content = $contents->text; } @@ -248,16 +251,16 @@ protected function question_survey_display($response, $descendantsdata, $blankqu $width = 30; } $nn = 100 - ($width * 2); - $colwidth = ($nn / $this->length) . '%'; + $colwidth = ($nn / $this->length()) . '%'; $textalign = 'right'; $width = $width . '%'; } else if ($nocontent) { $width = '0%'; - $colwidth = (100 / $this->length) . '%'; + $colwidth = (100 / $this->length()) . '%'; $textalign = 'right'; } else { $width = '59%'; - $colwidth = (40 / $this->length) . '%'; + $colwidth = (40 / $this->length()) . '%'; $textalign = 'left'; } @@ -277,7 +280,7 @@ protected function question_survey_display($response, $descendantsdata, $blankqu if (!$this->no_duplicate_choices()) { $nbchoices = count($this->choices); } else { // If "No duplicate choices", can restrict nbchoices to number of rate items specified. - $nbchoices = $this->length; + $nbchoices = $this->length(); } // Display empty td for Not yet answered column. @@ -289,7 +292,7 @@ protected function question_survey_display($response, $descendantsdata, $blankqu if ($nameddegrees > 0) { $currentdegree = reset($this->nameddegrees); } - for ($j = 1; $j <= $this->length; $j++) { + for ($j = 1; $j <= $this->length(); $j++) { $col = []; if (($nameddegrees > 0) && ($currentdegree !== false)) { $str = format_text($currentdegree, FORMAT_HTML, ['noclean' => true]); @@ -315,7 +318,7 @@ protected function question_survey_display($response, $descendantsdata, $blankqu $num = 0; foreach ($this->choices as $cid => $choice) { - $num += (isset($response->answers[$this->id][$cid]) && ($response->answers[$this->id][$cid]->value != -999)); + $num += (isset($response->answers[$this->id()][$cid]) && ($response->answers[$this->id()][$cid]->value != -999)); } $notcomplete = false; @@ -329,7 +332,7 @@ protected function question_survey_display($response, $descendantsdata, $blankqu foreach ($this->choices as $cid => $choice) { $cols = []; if (isset($choice->content)) { - $str = 'q' . "{$this->id}_$cid"; + $str = 'q' . "{$this->id()}_$cid"; $content = $choice->content; $rendercontent = format_text($choice->content, FORMAT_PLAIN); if ($this->osgood_rate_scale()) { @@ -339,7 +342,7 @@ protected function question_survey_display($response, $descendantsdata, $blankqu $othertext = $choice->other_choice_display(); $oname = $cid . '_qother'; $oid = $cid . '-other'; - $odata = isset($response->answers[$this->id][$cid]) ? $response->answers[$this->id][$cid]->value : ''; + $odata = isset($response->answers[$this->id()][$cid]) ? $response->answers[$this->id()][$cid]->value : ''; if (isset($odata)) { $ovalue = stripslashes($odata); } @@ -360,8 +363,8 @@ protected function question_survey_display($response, $descendantsdata, $blankqu $completeclass = 'notanswered'; $title = ''; if ( - $notcomplete && isset($response->answers[$this->id][$cid]) && - ($response->answers[$this->id][$cid]->value == -999) + $notcomplete && isset($response->answers[$this->id()][$cid]) && + ($response->answers[$this->id()][$cid]->value == -999) ) { $completeclass = 'notcompleted'; $title = get_string('pleasecomplete', 'questionnaire'); @@ -387,7 +390,7 @@ protected function question_survey_display($response, $descendantsdata, $blankqu reset($this->nameddegrees); } $colstart = $hasnotansweredchoice ? self::COL_START + 1 : self::COL_START; - for ($j = 1; $j <= $this->length + $this->has_na_column(); $j++) { + for ($j = 1; $j <= $this->length() + $this->has_na_column(); $j++) { if (!isset($collabel[$j])) { // If not using this value, continue. continue; @@ -399,9 +402,9 @@ protected function question_survey_display($response, $descendantsdata, $blankqu $value = key($this->nameddegrees); next($this->nameddegrees); } else { - $value = ($j <= $this->length ? $j : -1); + $value = ($j <= $this->length() ? $j : -1); } - if (isset($response->answers[$this->id][$cid]) && ($value == $response->answers[$this->id][$cid]->value)) { + if (isset($response->answers[$this->id()][$cid]) && ($value == $response->answers[$this->id()][$cid]->value)) { $checked = ' checked="checked"'; } $col['colstyle'] = 'text-align:center'; @@ -443,7 +446,7 @@ protected function question_survey_display($response, $descendantsdata, $blankqu /** * Return the context tags for the rate response template. - * @param \mod_questionnaire\responsetype\response\response $response + * @param \mod_questionnaire\local\response\response $response * @return \stdClass The rate question response context tags. * @throws \coding_exception */ @@ -454,8 +457,8 @@ protected function response_survey_display($response) { $resptags->headers = []; $resptags->rows = []; - if (!isset($response->answers[$this->id])) { - $response->answers[$this->id][] = new \mod_questionnaire\responsetype\answer\answer(); + if (!isset($response->answers[$this->id()])) { + $response->answers[$this->id()][] = new \mod_questionnaire\local\response\answer\answer(); } // Check if rate question has one line only to display full width columns of choices. $nocontent = false; @@ -486,18 +489,18 @@ protected function response_survey_display($response) { } $nn = 100 - ($sidecolwidthn * 2); $resptags->sidecolwidth = $sidecolwidth; - $resptags->colwidth = ($nn / $this->length) . '%'; + $resptags->colwidth = ($nn / $this->length()) . '%'; $resptags->textalign = 'right'; } else { $resptags->sidecolwidth = '49%'; - $resptags->colwidth = (50 / $this->length) . '%'; + $resptags->colwidth = (50 / $this->length()) . '%'; $resptags->textalign = 'left'; } if (!empty($this->nameddegrees)) { - $this->length = count($this->nameddegrees); + $this->set_length(count($this->nameddegrees)); reset($this->nameddegrees); } - for ($j = 1; $j <= $this->length; $j++) { + for ($j = 1; $j <= $this->length(); $j++) { $cellobj = new \stdClass(); $cellobj->bg = $bg; if (!empty($this->nameddegrees)) { @@ -524,9 +527,9 @@ protected function response_survey_display($response) { $rowobj = new \stdClass(); // Do not print column names if named column exist. if (!array_key_exists($cid, $cidnamed)) { - $str = 'q' . "{$this->id}_$cid"; + $str = 'q' . "{$this->id()}_$cid"; $content = $choice->content; - $contents = questionnaire_choice_values($content); + $contents = question::parse_choice_content($content); if ($contents->modname) { $content = $contents->text; } @@ -535,33 +538,34 @@ protected function response_survey_display($response) { } if ($choice->is_other_choice()) { $content = $choice->other_choice_display(); - if (isset($response->answers[$this->id][$cid]->otheresponse)) { - $rowobj->othercontent = $response->answers[$this->id][$cid]->otheresponse; + if (isset($response->answers[$this->id()][$cid]->otheresponse)) { + $rowobj->othercontent = $response->answers[$this->id()][$cid]->otheresponse; } } $rowobj->content = format_text($content, FORMAT_HTML, ['noclean' => true]) . ' '; $bg = 'c0'; $cols = []; if (!empty($this->nameddegrees)) { - $this->length = count($this->nameddegrees); + $this->set_length(count($this->nameddegrees)); reset($this->nameddegrees); } - for ($j = 1; $j <= $this->length; $j++) { + for ($j = 1; $j <= $this->length(); $j++) { $cellobj = new \stdClass(); - if (isset($response->answers[$this->id][$cid])) { + if (isset($response->answers[$this->id()][$cid])) { if (!empty($this->nameddegrees)) { - if ($response->answers[$this->id][$cid]->value == key($this->nameddegrees)) { + if ($response->answers[$this->id()][$cid]->value == key($this->nameddegrees)) { $cellobj->checked = 1; } next($this->nameddegrees); - } else if ($j == $response->answers[$this->id][$cid]->value) { + } else if ($j == $response->answers[$this->id()][$cid]->value) { $cellobj->checked = 1; } } $cellobj->str = $str . $j . $uniquetag++; $cellobj->bg = $bg; // N/A column checked. - $checkedna = (isset($response->answers[$this->id][$cid]) && ($response->answers[$this->id][$cid]->value == -1)); + $checkedna = (isset($response->answers[$this->id()][$cid]) && + ($response->answers[$this->id()][$cid]->value == -1)); if ($bg == 'c0') { $bg = 'c1'; } else { @@ -596,16 +600,16 @@ protected function response_survey_display($response) { * */ public function response_complete($responsedata) { - if (!is_a($responsedata, 'mod_questionnaire\responsetype\response\response')) { - $response = \mod_questionnaire\responsetype\response\response::response_from_webform($responsedata, [$this]); + if (!is_a($responsedata, 'mod_questionnaire\local\response\response')) { + $response = \mod_questionnaire\local\response\response::response_from_webform($responsedata, [$this]); } else { $response = $responsedata; } // To make it easier, create an array of answers by choiceid. $answers = []; - if (isset($response->answers[$this->id])) { - foreach ($response->answers[$this->id] as $answer) { + if (isset($response->answers[$this->id()])) { + foreach ($response->answers[$this->id()] as $answer) { $answers[$answer->choiceid] = $answer; } } @@ -646,8 +650,8 @@ public function response_complete($responsedata) { */ public function response_valid($responsedata) { // Work with a response object. - if (!is_a($responsedata, 'mod_questionnaire\responsetype\response\response')) { - $response = \mod_questionnaire\responsetype\response\response::response_from_webform($responsedata, [$this]); + if (!is_a($responsedata, 'mod_questionnaire\local\response\response')) { + $response = \mod_questionnaire\local\response\response::response_from_webform($responsedata, [$this]); } else { $response = $responsedata; } @@ -658,8 +662,8 @@ public function response_valid($responsedata) { // Create an answers array indexed by choiceid for ease. $answers = []; $nodups = []; - if (isset($response->answers[$this->id])) { - foreach ($response->answers[$this->id] as $answer) { + if (isset($response->answers[$this->id()])) { + foreach ($response->answers[$this->id()] as $answer) { $answers[$answer->choiceid] = $answer; $nodups[] = $answer->value; } @@ -681,9 +685,9 @@ public function response_valid($responsedata) { $nbchoices -= $nameddegrees; } // If nodupes and nb choice restricted, nbchoices may be > actual choices, so limit it to $question->length. - $isrestricted = ($this->length < count($this->choices)) && $this->no_duplicate_choices(); + $isrestricted = ($this->length() < count($this->choices)) && $this->no_duplicate_choices(); if ($isrestricted) { - $nbchoices = min($nbchoices, $this->length); + $nbchoices = min($nbchoices, $this->length()); } // Test for duplicate answers in a no duplicate question type. @@ -764,7 +768,7 @@ protected function form_preprocess_data($formdata) { foreach ($nameddegreelines as $nameddegreeline) { $nameddegreeline = trim($nameddegreeline); if ( - ($nameddegree = \mod_questionnaire\question\choice::content_is_named_degree_choice($nameddegreeline)) !== + ($nameddegree = \mod_questionnaire\local\question\choice::content_is_named_degree_choice($nameddegreeline)) !== false ) { $nameddegrees += $nameddegree; @@ -828,7 +832,7 @@ protected function form_preprocess_choicedata($formdata) { * @return bool */ public function update_choice($choicerecord) { - if ($nameddegree = \mod_questionnaire\question\choice::content_is_named_degree_choice($choicerecord->content)) { + if ($nameddegree = \mod_questionnaire\local\question\choice::content_is_named_degree_choice($choicerecord->content)) { // Preserve any existing value from the new array. $this->nameddegrees = $nameddegree + $this->nameddegrees; $this->insert_nameddegrees($this->nameddegrees); @@ -842,7 +846,7 @@ public function update_choice($choicerecord) { * @return bool */ public function add_choice($choicerecord) { - if ($nameddegree = \mod_questionnaire\question\choice::content_is_named_degree_choice($choicerecord->content)) { + if ($nameddegree = \mod_questionnaire\local\question\choice::content_is_named_degree_choice($choicerecord->content)) { // Preserve any existing value from the new array. $this->nameddegrees = $nameddegree + $this->nameddegrees; $this->insert_nameddegrees($this->nameddegrees); @@ -887,9 +891,9 @@ public function mobile_question_choices_display() { $cnum = 0; foreach ($this->choices as $choiceid => $choice) { $choice->na = false; - $choice->choice_id = $choiceid; + $choice->choiceid = $choiceid; $choice->id = $choiceid; - $choice->question_id = $this->id; + $choice->questionid = $this->id(); // Add a fieldkey for each choice. $choice->fieldkey = $this->mobile_fieldkey($choiceid); @@ -907,8 +911,8 @@ public function mobile_question_choices_display() { $choices[$cnum]->min = 0; $choices[$cnum]->minstr = 1; } - $choices[$cnum]->max = intval($this->length) - 1; - $choices[$cnum]->maxstr = intval($this->length); + $choices[$cnum]->max = intval($this->length()) - 1; + $choices[$cnum]->maxstr = intval($this->length()); } else if ($this->has_na_column()) { $choices[$cnum] = $choice; if ($this->required()) { @@ -918,7 +922,7 @@ public function mobile_question_choices_display() { $choices[$cnum]->min = 0; $choices[$cnum]->minstr = 1; } - $choices[$cnum]->max = intval($this->length); + $choices[$cnum]->max = intval($this->length()); $choices[$cnum]->na = true; } else { $excludes[$choiceid] = $choiceid; @@ -958,7 +962,7 @@ public function mobile_question_choices_display() { } if (!in_array($choiceid, $excludes)) { - $choice->choice_id = $choiceid; + $choice->choiceid = $choiceid; if ($choice->value == null) { $choice->value = ''; } @@ -981,7 +985,7 @@ public function mobile_question_rates_display() { $rates[] = (object)['value' => $value, 'label' => $label]; } } else { - for ($i = 1; $i <= $this->length; $i++) { + for ($i = 1; $i <= $this->length(); $i++) { $rates[] = (object)['value' => $i, 'label' => $i]; } } @@ -995,8 +999,8 @@ public function mobile_question_rates_display() { */ public function get_mobile_response_data($response) { $resultdata = []; - if (isset($response->answers[$this->id])) { - foreach ($response->answers[$this->id] as $answer) { + if (isset($response->answers[$this->id()])) { + foreach ($response->answers[$this->id()] as $answer) { // Add a fieldkey for each choice. if (!empty($this->nameddegrees)) { if (isset($this->nameddegrees[$answer->value])) { @@ -1016,8 +1020,8 @@ public function get_mobile_response_data($response) { * Add the nameddegrees property. */ private function add_nameddegrees_from_extradata() { - if (!empty($this->extradata)) { - $this->nameddegrees = json_decode($this->extradata, true); + if (!empty($this->extradata())) { + $this->nameddegrees = json_decode($this->extradata(), true); } } @@ -1038,8 +1042,6 @@ public function insert_nameddegrees(array $nameddegrees) { * @param \stdClass|null $questionrec */ public static function move_nameddegree_choices(int $qid = 0, ?\stdClass $questionrec = null) { - global $DB; - if ($qid !== 0) { $question = new rate($qid); } else { @@ -1064,24 +1066,17 @@ public static function move_nameddegree_choices(int $qid = 0, ?\stdClass $questi if ($question->insert_nameddegrees($nameddegrees)) { // Remove the old named desgree from the choices table. foreach ($oldchoiceids as $choiceid) { - \mod_questionnaire\question\choice::delete_from_db_by_id($choiceid); + \mod_questionnaire\local\question\choice::delete_from_db_by_id($choiceid); } - // First get all existing rank responses for this question. - $responses = $DB->get_recordset('questionnaire_response_rank', ['question_id' => $question->id]); - // Iterating over each response record ensures we won't change an existing record more than once. - foreach ($responses as $response) { - // Then, if the old value exists, set it to the new one. - if (isset($newvalues[$response->rankvalue])) { - $DB->set_field( - 'questionnaire_response_rank', - 'rankvalue', - $newvalues[$response->rankvalue], - ['id' => $response->id] - ); + // Translate every existing rank response for this question to its new value. + foreach (response_rank_record::get_for_question($question->id()) as $response) { + $oldvalue = $response->get('rankvalue'); + if (isset($newvalues[$oldvalue])) { + $response->set('rankvalue', $newvalues[$oldvalue]); + $response->update(); } } - $responses->close(); } } } @@ -1093,52 +1088,26 @@ public static function move_nameddegree_choices(int $qid = 0, ?\stdClass $questi * @param int|null $surveyid */ public static function move_all_nameddegree_choices(?int $surveyid = null) { - global $DB; - // This operation might take a while. Cancel PHP timeouts for this. \core_php_time_limit::raise(); - // First, let's adjust all rate answers from zero based to one based (see GHI223). - // If a specific survey is being dealt with, only use the questions from that survey. - $skip = false; - if ($surveyid !== null) { - $qids = $DB->get_records_menu( - 'questionnaire_question', - ['surveyid' => $surveyid, - 'type_id' => QUESRATE], - '', - 'id,surveyid' - ); - if (!empty($qids)) { - [$qsql, $qparams] = $DB->get_in_or_equal(array_keys($qids)); - } else { - // No relevant questions, so no need to do this step. - $skip = true; - } - } + $ratequests = question_record::get_for_type(QUESRATE, $surveyid); - // If we're doing this step, let's do it. - if (!$skip) { - $select = 'UPDATE {questionnaire_response_rank} ' . - 'SET rankvalue = (rankvalue + 1) ' . - 'WHERE (rankvalue >= 0)'; - if ($surveyid !== null) { - $select .= ' AND (question_id ' . $qsql . ')'; - } else { - $qparams = []; + // First, adjust all rate answers from zero based to one based (see GHI223). + // Restrict the bulk update to this survey's rate questions when one was supplied; + // skip entirely when the survey has none. + if ($surveyid !== null) { + if (empty($ratequests)) { + return; } - $DB->execute($select, $qparams); + response_rank_record::increment_rankvalues(array_map(fn($r) => $r->get('id'), $ratequests)); + } else { + response_rank_record::increment_rankvalues(); } - $args = ['type_id' => QUESRATE]; - if ($surveyid !== null) { - $args['surveyid'] = $surveyid; - } - $ratequests = $DB->get_recordset('questionnaire_question', $args); - foreach ($ratequests as $questionrec) { - self::move_nameddegree_choices(0, $questionrec); + foreach ($ratequests as $qrec) { + self::move_nameddegree_choices(0, $qrec->to_record()); } - $ratequests->close(); } /** diff --git a/classes/question/sectiontext.php b/classes/local/question/sectiontext.php similarity index 79% rename from classes/question/sectiontext.php rename to classes/local/question/sectiontext.php index 844590161..e8e32ef0d 100644 --- a/classes/question/sectiontext.php +++ b/classes/local/question/sectiontext.php @@ -14,9 +14,10 @@ // You should have received a copy of the GNU General Public License // along with Moodle. If not, see . -namespace mod_questionnaire\question; +namespace mod_questionnaire\local\question; -use mod_questionnaire\feedback\section; +use mod_questionnaire\local\db\feedback_section_record; +use mod_questionnaire\local\feedback\section; /** * This file contains the parent class for sectiontext question types. @@ -76,30 +77,30 @@ public function mobile_question_display($qnum, $autonum = false) { $options = ['noclean' => true, 'para' => false, 'filter' => true, 'context' => $this->context, 'overflowdiv' => true]; $mobiledata = (object)[ - 'id' => $this->id, - 'name' => $this->name, - 'type_id' => $this->type_id, - 'length' => $this->length, + 'id' => $this->id(), + 'name' => $this->name(), + 'typeid' => $this->typeid(), + 'length' => $this->length(), 'content' => format_text( file_rewrite_pluginfile_urls( - $this->content, + $this->content(), 'pluginfile.php', $this->context->id, 'mod_questionnaire', 'question', - $this->id + $this->id() ), FORMAT_HTML, $options ), - 'content_stripped' => strip_tags($this->content), + 'content_stripped' => strip_tags($this->content()), 'required' => false, - 'deleted' => $this->deleted, - 'response_table' => $this->responsetable, + 'deleted' => $this->deleted(), + 'response_table' => $this->questiontype ? $this->questiontype->responsetable : null, 'fieldkey' => $this->mobile_fieldkey(), - 'precise' => $this->precise, + 'precise' => $this->precise(), 'qnum' => '', - 'errormessage' => get_string('required') . ': ' . $this->name, + 'errormessage' => get_string('required') . ': ' . $this->name(), ]; $mobiledata->issectiontext = true; @@ -138,30 +139,30 @@ public function is_numbered() { /** * Return the context tags for the check question template. - * @param \mod_questionnaire\responsetype\response\response $response + * @param \mod_questionnaire\local\response\response $response * @param array $descendantsdata Array of all questions/choices depending on this question. * @param boolean $blankquestionnaire * @return object The check question context tags. * */ protected function question_survey_display($response, $descendantsdata, $blankquestionnaire = false) { - global $DB, $CFG, $PAGE; - require_once($CFG->dirroot . '/mod/questionnaire/questionnaire.class.php'); + // If not a response domain object, normal behavior as sectiontext question. + if (!($response instanceof \mod_questionnaire\local\response\response)) { + return ''; + } - // If !isset then normal behavior as sectiontext question. - if (!isset($response->questionnaireid)) { + // If no questionnaire context was passed through the rendering chain, fall back to normal behavior. + if ($this->questionnaire === null) { return ''; } $filteredsections = []; // In which section(s) is this question? - if ($fbsections = $DB->get_records('questionnaire_fb_sections', ['surveyid' => $this->surveyid])) { - foreach ($fbsections as $key => $fbsection) { - if ($scorecalculation = section::decode_scorecalculation($fbsection->scorecalculation)) { - if (array_key_exists($this->id, $scorecalculation)) { - array_push($filteredsections, $fbsection->section); - } + foreach (feedback_section_record::get_for_survey($this->surveyid()) as $fbsection) { + if ($scorecalculation = section::decode_scorecalculation($fbsection->get('scorecalculation'))) { + if (array_key_exists($this->id(), $scorecalculation)) { + array_push($filteredsections, $fbsection->get('section')); } } } @@ -171,16 +172,13 @@ protected function question_survey_display($response, $descendantsdata, $blankqu return ''; } - [$cm, $course, $questionnaire] = questionnaire_get_standard_page_items(null, $response->questionnaireid); - $questionnaire = new \questionnaire($course, $cm, 0, $questionnaire); - $questionnaire->add_renderer($PAGE->get_renderer('mod_questionnaire')); - $questionnaire->add_page(new \mod_questionnaire\output\reportpage()); + $questionnaire = $this->questionnaire; $compare = false; $allresponses = false; $currentgroupid = 0; $isgroupmember = false; - $rid = (isset($response->id) && !empty($response->id)) ? $response->id : 0; + $rid = $response->id(); $resps = [$rid => null]; // For $filteredsections -> get the feedback messages only for this sections! $feedbackmessages = $questionnaire->response_analysis( diff --git a/classes/question/slider.php b/classes/local/question/slider.php similarity index 88% rename from classes/question/slider.php rename to classes/local/question/slider.php index f308a0b61..83e059f2b 100644 --- a/classes/question/slider.php +++ b/classes/local/question/slider.php @@ -14,7 +14,7 @@ // You should have received a copy of the GNU General Public License // along with Moodle. If not, see . -namespace mod_questionnaire\question; +namespace mod_questionnaire\local\question; /** * This file contains the parent class for slider question types. @@ -30,7 +30,7 @@ class slider extends question { * @return string */ protected function responseclass() { - return '\\mod_questionnaire\\responsetype\\slider'; + return '\\mod_questionnaire\\local\\response\\slider'; } /** @@ -79,10 +79,10 @@ public function supports_feedback() { * @return bool */ public function valid_feedback() { - $extradata = json_decode($this->extradata); + $extradata = json_decode($this->extradata()); $minrange = $extradata->minrange; // Negative scores are not accepted in Feedback. - return $this->supports_feedback() && !empty($this->name) && $minrange >= 0; + return $this->supports_feedback() && !empty($this->name()) && $minrange >= 0; } /** @@ -91,7 +91,7 @@ public function valid_feedback() { */ public function get_feedback_maxscore() { if ($this->valid_feedback()) { - $extradata = json_decode($this->extradata); + $extradata = json_decode($this->extradata()); $maxscore = $extradata->maxrange; } else { $maxscore = false; @@ -102,7 +102,7 @@ public function get_feedback_maxscore() { /** * Return the context tags for the check question template. * - * @param \mod_questionnaire\responsetype\response\response $response + * @param \mod_questionnaire\local\response\response $response * @param array $dependants Array of all questions/choices depending on this question. * @param boolean $blankquestionnaire * @return object The check question context tags. @@ -110,14 +110,19 @@ public function get_feedback_maxscore() { */ protected function question_survey_display($response, $dependants = [], $blankquestionnaire = false) { global $PAGE; - $PAGE->requires->js_init_call('M.mod_questionnaire.init_slider', null, false, questionnaire_get_js_module()); - $extradata = json_decode($this->extradata); + $PAGE->requires->js_init_call( + 'M.mod_questionnaire.init_slider', + null, + false, + \mod_questionnaire\form_options::js_module() + ); + $extradata = json_decode($this->extradata()); $questiontags = new \stdClass(); - if (isset($response->answers[$this->id][0])) { - $extradata->startingvalue = $response->answers[$this->id][0]->value; + if (isset($response->answers[$this->id()][0])) { + $extradata->startingvalue = $response->answers[$this->id()][0]->value; } - $extradata->name = 'q' . $this->id; - $extradata->id = self::qtypename($this->type_id) . $this->id; + $extradata->name = 'q' . $this->id(); + $extradata->id = self::qtypename($this->typeid()) . $this->id(); $questiontags->qelements = new \stdClass(); $questiontags->qelements->extradata = $extradata; $questiontags->isprint = $this->get_isprint(); @@ -126,21 +131,26 @@ protected function question_survey_display($response, $dependants = [], $blankqu /** * Return the context tags for the slider response template. - * @param \mod_questionnaire\responsetype\response\response $response + * @param \mod_questionnaire\local\response\response $response * @return \stdClass The check question response context tags. */ protected function response_survey_display($response) { global $PAGE; - $PAGE->requires->js_init_call('M.mod_questionnaire.init_slider', null, false, questionnaire_get_js_module()); + $PAGE->requires->js_init_call( + 'M.mod_questionnaire.init_slider', + null, + false, + \mod_questionnaire\form_options::js_module() + ); $resptags = new \stdClass(); - if (isset($response->answers[$this->id])) { - $answer = reset($response->answers[$this->id]); + if (isset($response->answers[$this->id()])) { + $answer = reset($response->answers[$this->id()]); $resptags->content = format_text($answer->value, FORMAT_HTML); - if (!empty($response->answers[$this->id]['extradata'])) { - $resptags->extradata = $response->answers[$this->id]['extradata']; + if (!empty($response->answers[$this->id()]['extradata'])) { + $resptags->extradata = $response->answers[$this->id()]['extradata']; } else { - $extradata = json_decode($this->extradata); + $extradata = json_decode($this->extradata()); $resptags->extradata = $extradata; } } @@ -189,8 +199,8 @@ protected function form_extradata(\MoodleQuickForm $mform, $helpname = '') { $stepvalue = 'stepvalue'; $ranges = []; - if (!empty($this->extradata)) { - $ranges = json_decode($this->extradata); + if (!empty($this->extradata())) { + $ranges = json_decode($this->extradata()); } $mform->addElement('text', 'leftlabel', get_string('leftlabel', 'questionnaire')); $mform->setType('leftlabel', PARAM_RAW); @@ -331,7 +341,7 @@ public function mobile_question_display($qnum, $autonum = false) { * @return array */ public function mobile_otherdata() { - $extradata = json_decode($this->extradata); + $extradata = json_decode($this->extradata()); return [$this->mobile_fieldkey() => $extradata->startingvalue]; } } diff --git a/classes/question/text.php b/classes/local/question/text.php similarity index 86% rename from classes/question/text.php rename to classes/local/question/text.php index 40d990fa7..084ccddcc 100644 --- a/classes/question/text.php +++ b/classes/local/question/text.php @@ -14,7 +14,7 @@ // You should have received a copy of the GNU General Public License // along with Moodle. If not, see . -namespace mod_questionnaire\question; +namespace mod_questionnaire\local\question; /** * This file contains the parent class for text question types. @@ -33,8 +33,8 @@ class text extends question { * @param array $params */ public function __construct($id = 0, $question = null, $context = null, $params = []) { - $this->length = 20; - $this->precise = 25; + $this->set_length(20); + $this->set_precise(25); return parent::__construct($id, $question, $context, $params); } @@ -43,7 +43,7 @@ public function __construct($id = 0, $question = null, $context = null, $params * @return object The response object based off of questionnaire_response_base. */ protected function responseclass() { - return '\\mod_questionnaire\\responsetype\\text'; + return '\\mod_questionnaire\\local\\response\\text'; } /** @@ -83,14 +83,14 @@ protected function question_survey_display($response, $descendantsdata, $blankqu $questiontags->qelements = new \stdClass(); $choice = new \stdClass(); $choice->onkeypress = 'return event.keyCode != 13;'; - $choice->size = $this->length; - $choice->name = 'q' . $this->id; - if ($this->precise > 0) { - $choice->maxlength = $this->precise; + $choice->size = $this->length(); + $choice->name = 'q' . $this->id(); + if ($this->precise() > 0) { + $choice->maxlength = $this->precise(); } - $choice->value = (isset($response->answers[$this->id][0]) ? - format_string(stripslashes($response->answers[$this->id][0]->value)) : ''); - $choice->id = self::qtypename($this->type_id) . $this->id; + $choice->value = (isset($response->answers[$this->id()][0]) ? + format_string(stripslashes($response->answers[$this->id()][0]->value)) : ''); + $choice->id = self::qtypename($this->typeid()) . $this->id(); $questiontags->qelements->choice = $choice; $questiontags->isprint = $this->get_isprint(); return $questiontags; @@ -102,8 +102,8 @@ protected function question_survey_display($response, $descendantsdata, $blankqu */ protected function response_survey_display($response) { $resptags = new \stdClass(); - if (isset($response->answers[$this->id])) { - $answer = reset($response->answers[$this->id]); + if (isset($response->answers[$this->id()])) { + $answer = reset($response->answers[$this->id()]); $resptags->content = format_text($answer->value, FORMAT_HTML); } return $resptags; @@ -155,8 +155,8 @@ public function mobile_question_choices_display() { $choices = []; $choices[0] = new \stdClass(); $choices[0]->id = 0; - $choices[0]->choice_id = 0; - $choices[0]->question_id = $this->id; + $choices[0]->choiceid = 0; + $choices[0]->questionid = $this->id(); $choices[0]->content = ''; $choices[0]->value = null; return $choices; diff --git a/classes/question/yesno.php b/classes/local/question/yesno.php similarity index 86% rename from classes/question/yesno.php rename to classes/local/question/yesno.php index 3c9f51268..8dda77d2e 100644 --- a/classes/question/yesno.php +++ b/classes/local/question/yesno.php @@ -14,7 +14,7 @@ // You should have received a copy of the GNU General Public License // along with Moodle. If not, see . -namespace mod_questionnaire\question; +namespace mod_questionnaire\local\question; /** * This file contains the parent class for yesno question types. @@ -30,7 +30,7 @@ class yesno extends question { * @return object The response object based off of questionnaire_response_base. */ protected function responseclass() { - return '\\mod_questionnaire\\responsetype\\boolean'; + return '\\mod_questionnaire\\local\\response\\boolean'; } /** @@ -101,16 +101,16 @@ public function get_feedback_maxscore() { */ protected function get_dependency_options() { $options = []; - if ($this->name != '') { - $options[$this->id . ',0'] = $this->name . '->' . get_string('yes'); - $options[$this->id . ',1'] = $this->name . '->' . get_string('no'); + if ($this->name() != '') { + $options[$this->id() . ',0'] = $this->name() . '->' . get_string('yes'); + $options[$this->id() . ',1'] = $this->name() . '->' . get_string('no'); } return $options; } /** * Return the context tags for the check question template. - * @param \mod_questionnaire\responsetype\response\response $response + * @param \mod_questionnaire\local\response\response $response * @param array $dependants Array of all questions/choices depending on this question. * @param boolean $blankquestionnaire * @return object The check question context tags. @@ -131,8 +131,8 @@ protected function question_survey_display($response, $dependants = [], $blankqu } $options = [$val1 => $stryes, $val2 => $strno]; - $name = 'q' . $this->id; - $checked = (isset($response->answers[$this->id][0]) ? $response->answers[$this->id][0]->value : ''); + $name = 'q' . $this->id(); + $checked = (isset($response->answers[$this->id()][0]) ? $response->answers[$this->id()][0]->value : ''); $ischecked = false; $choicetags = new \stdClass(); @@ -183,7 +183,7 @@ protected function question_survey_display($response, $dependants = [], $blankqu /** * Return the context tags for the text response template. - * @param \mod_questionnaire\responsetype\response\response $response + * @param \mod_questionnaire\local\response\response $response * @return object The radio question response context tags. * @throws \coding_exception */ @@ -192,14 +192,14 @@ protected function response_survey_display($response) { $resptags = new \stdClass(); - $resptags->yesname = 'q' . $this->id . $uniquetag++ . 'y'; - $resptags->noname = 'q' . $this->id . $uniquetag++ . 'n'; + $resptags->yesname = 'q' . $this->id() . $uniquetag++ . 'y'; + $resptags->noname = 'q' . $this->id() . $uniquetag++ . 'n'; $resptags->stryes = get_string('yes'); $resptags->strno = get_string('no'); - if (!isset($response->answers[$this->id])) { - $response->answers[$this->id][] = new \mod_questionnaire\responsetype\answer\answer(); + if (!isset($response->answers[$this->id()])) { + $response->answers[$this->id()][] = new \mod_questionnaire\local\response\answer\answer(); } - $answer = reset($response->answers[$this->id]); + $answer = reset($response->answers[$this->id()]); if ($answer->value == 'y') { $resptags->yesselected = 1; } @@ -261,15 +261,15 @@ public function mobile_question_choices_display() { $choices = []; $choices[0] = new \stdClass(); $choices[0]->id = 0; - $choices[0]->choice_id = 'n'; - $choices[0]->question_id = $this->id; + $choices[0]->choiceid = 'n'; + $choices[0]->questionid = $this->id(); $choices[0]->value = null; $choices[0]->content = get_string('no'); $choices[0]->isbool = true; $choices[1] = new \stdClass(); $choices[1]->id = 1; - $choices[1]->choice_id = 'y'; - $choices[1]->question_id = $this->id; + $choices[1]->choiceid = 'y'; + $choices[1]->questionid = $this->id(); $choices[1]->value = null; $choices[1]->content = get_string('yes'); $choices[1]->isbool = true; @@ -288,9 +288,9 @@ public function mobile_question_choices_display() { */ public function get_mobile_response_data($response) { $resultdata = []; - if (isset($response->answers[$this->id][0]) && ($response->answers[$this->id][0]->value == 'n')) { + if (isset($response->answers[$this->id()][0]) && ($response->answers[$this->id()][0]->value == 'n')) { $resultdata[$this->mobile_fieldkey()] = 0; - } else if (isset($response->answers[$this->id][0]) && ($response->answers[$this->id][0]->value == 'y')) { + } else if (isset($response->answers[$this->id()][0]) && ($response->answers[$this->id()][0]->value == 'y')) { $resultdata[$this->mobile_fieldkey()] = 1; } diff --git a/classes/local/question_navigator.php b/classes/local/question_navigator.php new file mode 100644 index 000000000..6b4b09416 --- /dev/null +++ b/classes/local/question_navigator.php @@ -0,0 +1,264 @@ +. + +namespace mod_questionnaire\local; + +use mod_questionnaire\survey; +use mod_questionnaire\local\question\question; +use stdClass; + +/** + * Stateless navigator for moving through a survey's pages and questions. + * + * Encapsulates page-traversal and dependency-inspection logic that previously + * lived on the survey and questionnaire classes. Accepts the survey (for + * question/page structure) and a skip-logic flag (from the questionnaire module + * record) at construction time, then reads question state fresh on each call. + * + * @package mod_questionnaire + * @copyright 2026 onward Mike Churchward (mike.churchward@poetopensource.org) + * @author Mike Churchward + * @license http://www.gnu.org/copyleft/gpl.html GNU Public License + */ +class question_navigator { + /** @var survey The survey whose pages and questions are navigated. */ + private survey $survey; + + /** @var bool True when the questionnaire module has navigate > 0. */ + private bool $skiplogicenabled; + + /** + * Construct a navigator for the given survey. + * + * @param survey $survey The survey whose pages and questions are navigated. + * @param bool $skiplogicenabled True when the questionnaire module has navigate=1. + */ + public function __construct(survey $survey, bool $skiplogicenabled) { + $this->survey = $survey; + $this->skiplogicenabled = $skiplogicenabled; + } + + // Dependency inspection. + + /** + * True if skip-logic is enabled and at least one question carries a dependency condition. + * + * @return bool + */ + public function has_dependencies(): bool { + if (!$this->skiplogicenabled) { + return false; + } + foreach ($this->survey->questions() as $question) { + if ($question->has_dependencies()) { + return true; + } + } + return false; + } + + /** + * Get the IDs of all questions that depend on the given question. + * + * @param int $questionid + * @return array + */ + public function get_dependants(int $questionid): array { + $qu = []; + foreach ($this->survey->questions() as $question) { + if ($question->has_dependencies()) { + foreach ($question->dependencies as $dependency) { + if (($dependency->dependquestionid == $questionid) && !in_array($question->id(), $qu)) { + $qu[] = $question->id(); + } + } + } + } + return $qu; + } + + /** + * Get all direct and indirect dependants of a question. + * + * @param int $questionid + * @return stdClass Object with ->directs and ->indirects arrays. + */ + public function get_all_dependants(int $questionid): stdClass { + $questions = $this->survey->questions(); + $directids = $this->get_dependants($questionid); + $directs = []; + $indirects = []; + foreach ($directids as $directid) { + $this->load_parents($questions[$directid]); + $indirectids = $this->get_dependants($directid); + foreach ($questions[$directid]->dependencies as $dep) { + if ($dep->dependquestionid == $questionid) { + $directs[$directid][] = $dep; + } + } + foreach ($indirectids as $indirectid) { + $this->load_parents($questions[$indirectid]); + foreach ($questions[$indirectid]->dependencies as $dep) { + if ($dep->dependquestionid != $questionid) { + $indirects[$indirectid][] = $dep; + } + } + } + } + $alldependants = new stdClass(); + $alldependants->directs = $directs; + $alldependants->indirects = $indirects; + return $alldependants; + } + + /** + * Get all descendants and their choice conditions, keyed by parent question id. + * + * @return array + */ + public function get_dependants_and_choices(): array { + $questions = array_reverse($this->survey->questions(), true); + $parents = []; + foreach ($questions as $question) { + foreach ($question->dependencies as $dependency) { + $child = new stdClass(); + $child->choiceid = $dependency->dependchoiceid; + $child->logic = $dependency->dependlogic; + $child->andor = $dependency->dependandor; + $parents[$dependency->dependquestionid][$question->id()][] = $child; + } + } + return $parents; + } + + /** + * Load display info for each dependency of a question (parent name, choice label, etc.). + * + * @param question $question + * @return bool + */ + public function load_parents(question $question): bool { + $questions = $this->survey->questions(); + foreach ($question->dependencies as $did => $dependency) { + $dependquestion = $questions[$dependency->dependquestionid]; + $qdependchoice = ''; + switch ($dependquestion->typeid()) { + case QUESRADIO: + case QUESDROP: + case QUESCHECK: + $qdependchoice = $dependency->dependchoiceid; + $dependchoice = $dependquestion->choices[$dependency->dependchoiceid]->content; + $contents = question::parse_choice_content($dependchoice); + if ($contents->modname) { + $dependchoice = $contents->modname; + } + break; + case QUESYESNO: + switch ($dependency->dependchoiceid) { + case 0: + $dependchoice = get_string('yes'); + $qdependchoice = 'y'; + break; + case 1: + $dependchoice = get_string('no'); + $qdependchoice = 'n'; + break; + default: + $dependchoice = ''; + } + break; + default: + $dependchoice = ''; + } + $question->dependencies[$did]->qdependquestion = 'q' . $dependquestion->id(); + $question->dependencies[$did]->qdependchoice = $qdependchoice; + $question->dependencies[$did]->parenttype = $dependquestion->typeid(); + $question->dependencies[$did]->position = $question->position(); + $question->dependencies[$did]->name = $question->name(); + $question->dependencies[$did]->content = $question->content(); + $question->dependencies[$did]->parentposition = $dependquestion->position(); + $question->dependencies[$did]->parent = + format_string($dependquestion->name()) . '->' . format_string($dependchoice); + } + return true; + } + + // Page navigation. + + /** + * True if there are any eligible (dependency-satisfied) questions on the given section. + * + * @param int $secnum 1-based section number. + * @param int $rid Response id (0 if no response yet). + * @return bool + */ + public function eligible_questions_on_page(int $secnum, int $rid): bool { + $questions = $this->survey->questions(); + foreach ($this->survey->questions_by_section($secnum) as $question) { + if ($question->dependency_fulfilled($rid, $questions)) { + return true; + } + } + return false; + } + + /** + * Return the next valid section number after $secnum, or false if none. + * + * When skip-logic is active, skips pages whose questions are all dependency-blocked. + * + * @param int $secnum Current section number. + * @param int $rid Response id. + * @return int|bool + */ + public function next_page(int $secnum, int $rid): int|bool { + $secnum++; + $questionsbysec = $this->survey->questions_by_section_all(); + $numsections = !empty($questionsbysec) ? count($questionsbysec) : 0; + if ($this->has_dependencies()) { + while (!$this->eligible_questions_on_page($secnum, $rid)) { + $secnum++; + if ($secnum > $numsections) { + $secnum = false; + break; + } + } + } + return $secnum; + } + + /** + * Return the previous valid section number before $secnum, or false if none. + * + * When skip-logic is active, skips pages whose questions are all dependency-blocked. + * + * @param int $secnum Current section number. + * @param int $rid Response id. + * @return int|bool + */ + public function prev_page(int $secnum, int $rid): int|bool { + $secnum--; + if ($this->has_dependencies()) { + while (($secnum > 0) && !$this->eligible_questions_on_page($secnum, $rid)) { + $secnum--; + } + } + if ($secnum === 0) { + $secnum = false; + } + return $secnum; + } +} diff --git a/classes/local/question_type.php b/classes/local/question_type.php new file mode 100644 index 000000000..6411720de --- /dev/null +++ b/classes/local/question_type.php @@ -0,0 +1,177 @@ +. + +namespace mod_questionnaire\local; + +use mod_questionnaire\local\db\question_type_record; + +/** + * The main class for question type definitions. + * + * Replaces the scattered define() constants throughout the plugin with typed + * class constants and a DB-backed factory. All code referencing QUESCHOOSE etc. + * should migrate to question_type::QUESCHOOSE. + * + * @package mod_questionnaire + * @copyright 2025 onward Mike Churchward (mike.churchward@poetgroup.org) + * @author Mike Churchward + * @license http://www.gnu.org/copyleft/gpl.html GNU Public License + */ +class question_type { + /** @var int Define choose question type. */ + const QUESCHOOSE = 0; + /** @var int Define Yes/No question type. */ + const QUESYESNO = 1; + /** @var int Define text question type. */ + const QUESTEXT = 2; + /** @var int Define essay question type. */ + const QUESESSAY = 3; + /** @var int Define radio question type. */ + const QUESRADIO = 4; + /** @var int Define check question type. */ + const QUESCHECK = 5; + /** @var int Define drop question type. */ + const QUESDROP = 6; + /** @var int Define rate question type. */ + const QUESRATE = 8; + /** @var int Define date question type. */ + const QUESDATE = 9; + /** @var int Define numeric question type. */ + const QUESNUMERIC = 10; + /** @var int Define slider question type. */ + const QUESSLIDER = 11; + /** @var int Define file question type. */ + const QUESFILE = 12; + /** @var int Define page break question type. */ + const QUESPAGEBREAK = 99; + /** @var int Define section text question type. */ + const QUESSECTIONTEXT = 100; + + /** @var string The type name. */ + public string $type; + + /** @var string Whether the type has choices ('y' or 'n'). */ + public string $haschoices; + + /** @var string|null The response table name. */ + public ?string $responsetable; + + /** @var array Per-request cache of question_type instances keyed by typeid. */ + private static array $typecache = []; + + /** @var array Maps typeid integer → short name string. */ + private static array $qtypenames = [ + self::QUESYESNO => 'yesno', + self::QUESTEXT => 'text', + self::QUESESSAY => 'essay', + self::QUESRADIO => 'radio', + self::QUESCHECK => 'check', + self::QUESDROP => 'drop', + self::QUESRATE => 'rate', + self::QUESDATE => 'date', + self::QUESFILE => 'file', + self::QUESNUMERIC => 'numerical', + self::QUESPAGEBREAK => 'pagebreak', + self::QUESSECTIONTEXT => 'sectiontext', + self::QUESSLIDER => 'slider', + ]; + + /** + * Construct from a question_type_record persistent. + * + * @param question_type_record $qtyperecord + */ + public function __construct(question_type_record $qtyperecord) { + $this->type = $qtyperecord->get('type'); + $this->haschoices = $qtyperecord->get('haschoices'); + $this->responsetable = $qtyperecord->get('responsetable'); + } + + /** + * Return a question_type instance for the given typeid, or null if the typeid + * does not exist in the questionnaire_question_type table. + * + * Results are cached per request so the DB is queried at most once per typeid. + * + * @param int $typeid + * @return question_type|null + */ + public static function from_typeid(int $typeid): ?self { + if (!array_key_exists($typeid, self::$typecache)) { + $record = question_type_record::from_typeid($typeid); + self::$typecache[$typeid] = $record !== null ? new self($record) : null; + } + return self::$typecache[$typeid]; + } + + /** + * Return the short name for a question type integer. + * + * @param int $qtype + * @return string Empty string if the typeid is not known. + */ + public static function qtypename(int $qtype): string { + return self::$qtypenames[$qtype] ?? ''; + } + + /** + * Return the full map of typeid => short name. + * + * @return array + */ + public static function qtypenames(): array { + return self::$qtypenames; + } + + /** + * Return the localised display name for a question type integer. + * + * @param int $typeid + * @return string Empty string if the typeid is not known. + */ + public static function display_name(int $typeid): string { + switch ($typeid) { + case self::QUESYESNO: + return get_string('yesno', 'questionnaire'); + case self::QUESTEXT: + return get_string('textbox', 'questionnaire'); + case self::QUESESSAY: + return get_string('essaybox', 'questionnaire'); + case self::QUESRADIO: + return get_string('radiobuttons', 'questionnaire'); + case self::QUESCHECK: + return get_string('checkboxes', 'questionnaire'); + case self::QUESDROP: + return get_string('dropdown', 'questionnaire'); + case self::QUESRATE: + return get_string('ratescale', 'questionnaire'); + case self::QUESDATE: + return get_string('date', 'questionnaire'); + case self::QUESNUMERIC: + return get_string('numeric', 'questionnaire'); + case self::QUESSLIDER: + return get_string('slider', 'questionnaire'); + case self::QUESFILE: + return get_string('file', 'questionnaire'); + case self::QUESSECTIONTEXT: + return get_string('sectiontext', 'questionnaire'); + case self::QUESPAGEBREAK: + return get_string('sectionbreak', 'questionnaire'); + default: + return ''; + } + } +} diff --git a/classes/responsetype/answer/answer.php b/classes/local/response/answer/answer.php similarity index 98% rename from classes/responsetype/answer/answer.php rename to classes/local/response/answer/answer.php index d888af2e2..01d482ec3 100644 --- a/classes/responsetype/answer/answer.php +++ b/classes/local/response/answer/answer.php @@ -14,7 +14,7 @@ // You should have received a copy of the GNU General Public License // along with Moodle. If not, see . -namespace mod_questionnaire\responsetype\answer; +namespace mod_questionnaire\local\response\answer; /** * This defines a structured class to hold question answers. diff --git a/classes/responsetype/boolean.php b/classes/local/response/boolean.php similarity index 77% rename from classes/responsetype/boolean.php rename to classes/local/response/boolean.php index dadd92b2a..afba1a6e5 100644 --- a/classes/responsetype/boolean.php +++ b/classes/local/response/boolean.php @@ -14,7 +14,7 @@ // You should have received a copy of the GNU General Public License // along with Moodle. If not, see . -namespace mod_questionnaire\responsetype; +namespace mod_questionnaire\local\response; use coding_exception; use dml_exception; @@ -44,17 +44,17 @@ public static function response_table() { * 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. + * @param \mod_questionnaire\local\question\question $question + * @return array \mod_questionnaire\local\response\answer\answer An array of answer objects. */ public static function answers_from_webform($responsedata, $question) { $answers = []; - if (isset($responsedata->{'q' . $question->id}) && !empty($responsedata->{'q' . $question->id})) { + if (isset($responsedata->{'q' . $question->id()}) && !empty($responsedata->{'q' . $question->id()})) { $record = new \stdClass(); $record->responseid = $responsedata->rid; - $record->questionid = $question->id; - $record->choiceid = $responsedata->{'q' . $question->id}; - $record->value = $responsedata->{'q' . $question->id}; + $record->questionid = $question->id(); + $record->choiceid = $responsedata->{'q' . $question->id()}; + $record->value = $responsedata->{'q' . $question->id()}; $answers[] = answer\answer::create_from_data($record); } return $answers; @@ -64,12 +64,12 @@ public static function answers_from_webform($responsedata, $question) { * Provide an array of answer objects from mobile 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. + * @param \mod_questionnaire\local\question\question $question + * @return array \mod_questionnaire\local\response\answer\answer An array of answer objects. */ public static function answers_from_appdata($responsedata, $question) { - if (isset($responsedata->{'q' . $question->id}) && !empty($responsedata->{'q' . $question->id})) { - $responsedata->{'q' . $question->id} = ($responsedata->{'q' . $question->id}[0] == 1) ? 'y' : 'n'; + if (isset($responsedata->{'q' . $question->id()}) && !empty($responsedata->{'q' . $question->id()})) { + $responsedata->{'q' . $question->id()} = ($responsedata->{'q' . $question->id()}[0] == 1) ? 'y' : 'n'; } return static::answers_from_webform($responsedata, $question); } @@ -81,23 +81,24 @@ public static function answers_from_appdata($responsedata, $question) { * @return int|bool - on error the subtype should call set_error and return false. */ 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]); + if (!$responsedata instanceof \mod_questionnaire\local\response\response) { + $response = \mod_questionnaire\local\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->choice_id = $response->answers[$this->question->id][0]->choiceid; - return $DB->insert_record(static::response_table(), $record); - } else { - return false; + if (!empty($response) && isset($response->answers[$this->question->id()][0])) { + $rec = new \mod_questionnaire\local\db\response_bool_record(); + $rec->set('responseid', $response->id()); + $rec->set('questionid', $this->question->id()); + $rec->set('choiceid', $response->answers[$this->question->id()][0]->choiceid); + $rec->create(); + return $rec->get('id'); } + return false; } /** @@ -111,18 +112,18 @@ public function get_results($rids = false, $anonymous = false) { global $DB; $rsql = ''; - $params = [$this->question->id]; + $params = [$this->question->id()]; if (!empty($rids)) { [$rsql, $rparams] = $DB->get_in_or_equal($rids); $params = array_merge($params, $rparams); - $rsql = ' AND response_id ' . $rsql; + $rsql = ' AND responseid ' . $rsql; } $params[] = ''; - $sql = 'SELECT choice_id, COUNT(response_id) AS num ' . + $sql = 'SELECT choiceid, COUNT(responseid) AS num ' . 'FROM {' . static::response_table() . '} ' . - 'WHERE question_id= ? ' . $rsql . ' AND choice_id != ? ' . - 'GROUP BY choice_id'; + 'WHERE questionid= ? ' . $rsql . ' AND choiceid != ? ' . + 'GROUP BY choiceid'; return $DB->get_records_sql($sql, $params); } @@ -149,25 +150,25 @@ public function get_feedback_scores(array $rids) { global $DB; $rsql = ''; - $params = [$this->question->id]; + $params = [$this->question->id()]; if (!empty($rids)) { [$rsql, $rparams] = $DB->get_in_or_equal($rids); $params = array_merge($params, $rparams); - $rsql = ' AND response_id ' . $rsql; + $rsql = ' AND responseid ' . $rsql; } $params[] = 'y'; $feedbackscores = false; - $sql = 'SELECT response_id, choice_id ' . + $sql = 'SELECT responseid, choiceid ' . 'FROM {' . $this->response_table() . '} ' . - 'WHERE question_id= ? ' . $rsql . ' ' . - 'ORDER BY response_id ASC'; + 'WHERE questionid= ? ' . $rsql . ' ' . + 'ORDER BY responseid ASC'; if ($responses = $DB->get_recordset_sql($sql, $params)) { $feedbackscores = []; foreach ($responses as $rid => $response) { $feedbackscores[$rid] = new stdClass(); $feedbackscores[$rid]->rid = $rid; - $feedbackscores[$rid]->score = ($response->choice_id == 'y') ? 1 : 0; + $feedbackscores[$rid]->score = ($response->choiceid == 'y') ? 1 : 0; } } return $feedbackscores; @@ -209,7 +210,7 @@ public function display_results($rids = false, $sort = '', $anonymous = false) { $numrespondents = 0; if ($rows = $this->get_results($rids, $anonymous)) { foreach ($rows as $row) { - $choice = $row->choice_id; + $choice = $row->choiceid; $count = $row->num; if ($choice == 'y') { $choice = $stryes; @@ -236,14 +237,14 @@ public static function response_select($rid) { global $DB; $values = []; - $sql = 'SELECT q.id, q.content, a.choice_id ' . + $sql = 'SELECT q.id, q.content, a.choiceid ' . 'FROM {' . static::response_table() . '} a, {questionnaire_question} q ' . - 'WHERE a.response_id= ? AND a.question_id=q.id '; + 'WHERE a.responseid= ? AND a.questionid=q.id '; $records = $DB->get_records_sql($sql, [$rid]); foreach ($records as $qid => $row) { - $choice = $row->choice_id; + $choice = $row->choiceid; unset($row->id); - unset($row->choice_id); + unset($row->choiceid); $row = (array)$row; $newrow = []; foreach ($row as $key => $val) { @@ -271,9 +272,9 @@ public static function response_answers_by_question($rid) { global $DB; $answers = []; - $sql = 'SELECT id, response_id as responseid, question_id as questionid, choice_id as choiceid, choice_id as value ' . + $sql = 'SELECT id, responseid as responseid, questionid as questionid, choiceid as choiceid, choiceid as value ' . 'FROM {' . static::response_table() . '} ' . - 'WHERE response_id = ? '; + 'WHERE responseid = ? '; $records = $DB->get_records_sql($sql, [$rid]); foreach ($records as $record) { $record->choiceid = ($record->choiceid == 'y') ? 1 : 0; @@ -301,19 +302,19 @@ protected function bulk_sql() { global $DB; $userfields = $this->user_fields_sql(); - // Postgres requires all fields to be the same type. Boolean type returns a character value as "choice_id", + // Postgres requires all fields to be the same type. Boolean type returns a character value as "choiceid", // while all others are an integer. So put the boolean response in "response" field instead (CONTRIB-6436). - // NOTE - the actual use of "boolean" should probably change to not use "choice_id" at all, or use it as + // NOTE - the actual use of "boolean" should probably change to not use "choiceid" at all, or use it as // numeric zero and one instead. $alias = 'qrb'; - $extraselect = '0 AS choice_id, ' . $DB->sql_order_by_text('qrb.choice_id', 1000) . ' AS response, 0 AS rankvalue'; + $extraselect = '0 AS choiceid, ' . $DB->sql_order_by_text('qrb.choiceid', 1000) . ' AS response, 0 AS rankvalue'; return " SELECT " . $DB->sql_concat_join("'_'", ['qr.id', "'" . $this->question->helpname() . "'", $alias . '.id']) . " AS id, - qr.submitted, qr.complete, qr.grade, qr.userid, $userfields, qr.id AS rid, $alias.question_id, + qr.submitted, qr.complete, qr.grade, qr.userid, $userfields, qr.id AS rid, $alias.questionid AS questionid, $extraselect FROM {questionnaire_response} qr - JOIN {" . static::response_table() . "} $alias ON $alias.response_id = qr.id + JOIN {" . static::response_table() . "} $alias ON $alias.responseid = qr.id "; } } diff --git a/classes/responsetype/date.php b/classes/local/response/date.php similarity index 84% rename from classes/responsetype/date.php rename to classes/local/response/date.php index ff45de559..33af3e80f 100644 --- a/classes/responsetype/date.php +++ b/classes/local/response/date.php @@ -14,7 +14,7 @@ // You should have received a copy of the GNU General Public License // along with Moodle. If not, see . -namespace mod_questionnaire\responsetype; +namespace mod_questionnaire\local\response; use mod_questionnaire\db\bulk_sql_config; @@ -41,16 +41,16 @@ public static function response_table() { * 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. + * @param \mod_questionnaire\local\question\question $question + * @return array \mod_questionnaire\local\response\answer\answer An array of answer objects. */ public static function answers_from_webform($responsedata, $question) { $answers = []; - if (isset($responsedata->{'q' . $question->id}) && !empty($responsedata->{'q' . $question->id})) { + if (isset($responsedata->{'q' . $question->id()}) && !empty($responsedata->{'q' . $question->id()})) { $record = new \stdClass(); $record->responseid = $responsedata->rid; - $record->questionid = $question->id; - $record->value = $responsedata->{'q' . $question->id}; + $record->questionid = $question->id(); + $record->value = $responsedata->{'q' . $question->id()}; $answers[] = answer\answer::create_from_data($record); } return $answers; @@ -60,13 +60,13 @@ public static function answers_from_webform($responsedata, $question) { * Provide an array of answer objects from mobile 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. + * @param \mod_questionnaire\local\question\question $question + * @return array \mod_questionnaire\local\response\answer\answer An array of answer objects. */ public static function answers_from_appdata($responsedata, $question) { - if (isset($responsedata->{'q' . $question->id}) && !empty($responsedata->{'q' . $question->id})) { + if (isset($responsedata->{'q' . $question->id()}) && !empty($responsedata->{'q' . $question->id()})) { // The app can send the date including time (e.g. 2021-06-28T09:03:46.613+02:00), get only the date. - $responsedata->{'q' . $question->id} = substr($responsedata->{'q' . $question->id}[0], 0, 10); + $responsedata->{'q' . $question->id()} = substr($responsedata->{'q' . $question->id()}[0], 0, 10); } return static::answers_from_webform($responsedata, $question); } @@ -78,28 +78,29 @@ public static function answers_from_appdata($responsedata, $question) { * @return int|bool - on error the subtype should call set_error and return false. */ 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]); + if (!$responsedata instanceof \mod_questionnaire\local\response\response) { + $response = \mod_questionnaire\local\response\response::response_from_webform( + $responsedata, + [$this->question] + ); } else { $response = $responsedata; } - if (!empty($response) && isset($response->answers[$this->question->id][0])) { - $thisdate = $response->answers[$this->question->id][0]->value; + if (!empty($response) && isset($response->answers[$this->question->id()][0])) { + $thisdate = $response->answers[$this->question->id()][0]->value; if (!$this->question->check_date_format($thisdate)) { return false; } // Now use ISO date formatting. - $record = new \stdClass(); - $record->response_id = $response->id; - $record->question_id = $this->question->id; - $record->response = $thisdate; - return $DB->insert_record(self::response_table(), $record); - } else { - return false; + $rec = new \mod_questionnaire\local\db\response_date_record(); + $rec->set('responseid', $response->id()); + $rec->set('questionid', $this->question->id()); + $rec->set('response', $thisdate); + $rec->create(); + return $rec->get('id'); } + return false; } /** @@ -113,16 +114,16 @@ public function get_results($rids = false, $anonymous = false) { global $DB; $rsql = ''; - $params = [$this->question->id]; + $params = [$this->question->id()]; if (!empty($rids)) { [$rsql, $rparams] = $DB->get_in_or_equal($rids); $params = array_merge($params, $rparams); - $rsql = ' AND response_id ' . $rsql; + $rsql = ' AND responseid ' . $rsql; } $sql = 'SELECT id, response ' . 'FROM {' . static::response_table() . '} ' . - 'WHERE question_id= ? ' . $rsql; + 'WHERE questionid= ? ' . $rsql; return $DB->get_records_sql($sql, $params); } @@ -224,7 +225,7 @@ public static function response_select($rid) { $values = []; $sql = 'SELECT q.id, q.content, a.response as aresponse ' . 'FROM {' . static::response_table() . '} a, {questionnaire_question} q ' . - 'WHERE a.response_id=? AND a.question_id=q.id '; + 'WHERE a.responseid=? AND a.questionid=q.id '; $records = $DB->get_records_sql($sql, [$rid]); $dateformat = get_string('strfdate', 'questionnaire'); foreach ($records as $qid => $row) { @@ -264,9 +265,9 @@ 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, response as value ' . + $sql = 'SELECT id, responseid as responseid, questionid as questionid, 0 as choiceid, response as value ' . 'FROM {' . static::response_table() . '} ' . - 'WHERE response_id = ? '; + 'WHERE responseid = ? '; $records = $DB->get_records_sql($sql, [$rid]); foreach ($records as $record) { // Leave the date format in data storage format. diff --git a/classes/responsetype/file.php b/classes/local/response/file.php similarity index 80% rename from classes/responsetype/file.php rename to classes/local/response/file.php index 116afa16c..308fa43b8 100644 --- a/classes/responsetype/file.php +++ b/classes/local/response/file.php @@ -14,7 +14,7 @@ // You should have received a copy of the GNU General Public License // along with Moodle. If not, see . -namespace mod_questionnaire\responsetype; +namespace mod_questionnaire\local\response; use mod_questionnaire\db\bulk_sql_config; use moodle_url; @@ -33,16 +33,16 @@ 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. + * @param \mod_questionnaire\local\question\question $question + * @return array \mod_questionnaire\local\response\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}; + 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; + $record->questionid = $question->id(); file_save_draft_area_files( $val, @@ -50,7 +50,7 @@ public static function answers_from_webform($responsedata, $question) { 'mod_questionnaire', 'response_file', $val, - \mod_questionnaire\question\file::get_file_manager_option() + \mod_questionnaire\local\question\file::get_file_manager_option() ); $fs = get_file_storage(); $files = $fs->get_area_files( @@ -66,7 +66,7 @@ public static function answers_from_webform($responsedata, $question) { $record->value = $file->get_id(); $answers[] = answer\answer::create_from_data($record); } else { - self::delete_old_response((int)$question->id, (int)$record->responseid); + self::delete_old_response((int)$question->id(), (int)$record->responseid); } } return $answers; @@ -84,7 +84,7 @@ public static function response_select($rid) { $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 '; + 'WHERE a.responseid=? AND a.questionid=q.id '; $records = $DB->get_records_sql($sql, [$rid]); foreach ($records as $qid => $row) { unset($row->id); @@ -115,9 +115,9 @@ 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 ' . + $sql = 'SELECT id, responseid as responseid, questionid as questionid, 0 as choiceid, fileid as value ' . 'FROM {' . static::response_table() . '} ' . - 'WHERE response_id = ? '; + 'WHERE responseid = ? '; $records = $DB->get_records_sql($sql, [$rid]); foreach ($records as $record) { $answers[$record->questionid][] = answer\answer::create_from_data($record); @@ -136,66 +136,68 @@ public static function response_answers_by_question($rid) { */ 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, + // Check if we have an old response file from a former attempt. + $existing = \mod_questionnaire\local\db\response_file_record::get_records([ + 'responseid' => $responseid, + 'questionid' => $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', - 'filearea' => 'response_file', - 'itemid' => $record->id, - ] - ); - $DB->delete_records(self::response_table(), ['id' => $record->id]); + foreach ($existing as $rec) { + // Delete all referenced entries in the {files} table, then the response row. + // The {files} table is a Moodle core table — must remain a raw DB call. + $DB->delete_records('files', [ + 'component' => 'mod_questionnaire', + 'filearea' => 'response_file', + 'itemid' => $rec->get('id'), + ]); + $rec->delete(); } } /** * Insert a provided response to the question. * - * @param \mod_questionnaire\responsetype\response\response|\stdClass $responsedata + * @param \mod_questionnaire\local\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]); + if (!$responsedata instanceof \mod_questionnaire\local\response\response) { + $response = \mod_questionnaire\local\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)); + if (!empty($response) && isset($response->answers[$this->question->id()][0])) { + $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); + self::delete_old_response((int)$this->question->id(), (int)$response->id()); + + $rec = new \mod_questionnaire\local\db\response_file_record(); + $rec->set('responseid', $response->id()); + $rec->set('questionid', $this->question->id()); + $rec->set('fileid', $fileid); + $rec->create(); + $recordid = $rec->get('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'); + // When saving the draft file, the itemid was the same as the draftitemid. This must now + // be corrected to the primary key of questionnaire_response_file.id for a correct reference. + // The {files} table is Moodle core — these operations must remain raw DB calls. + $olditem = $DB->get_record('files', ['id' => $fileid], 'itemid'); if (!$olditem) { return false; } - $siblings = $DB->get_records( - 'files', - [ - 'component' => 'mod_questionnaire', - 'filearea' => 'response_file', - 'itemid' => $olditem->itemid, - ] - ); + $siblings = $DB->get_records('files', [ + 'component' => 'mod_questionnaire', + 'filearea' => 'response_file', + 'itemid' => $olditem->itemid, + ]); foreach ($siblings as $sibling) { if (!self::fix_file_itemid($recordid, $sibling)) { return false; @@ -296,7 +298,7 @@ public function get_results($rids = false, $anonymous = false) { $rsql = ''; if (!empty($rids)) { [$rsql, $params] = $DB->get_in_or_equal($rids); - $rsql = ' AND response_id ' . $rsql; + $rsql = ' AND responseid ' . $rsql; } if ($anonymous) { @@ -304,8 +306,8 @@ public function get_results($rids = false, $anonymous = false) { '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 ' . + 'WHERE questionid=' . $this->question->id() . $rsql . + ' AND t.responseid = r.id ' . 'ORDER BY r.submitted DESC'; } else { $sql = 'SELECT t.id, t.fileid, r.submitted AS submitted, r.userid, u.username AS username, ' . @@ -314,8 +316,8 @@ public function get_results($rids = false, $anonymous = false) { 'FROM {' . static::response_table() . '} t, ' . '{questionnaire_response} r, ' . '{user} u ' . - 'WHERE question_id=' . $this->question->id . $rsql . - ' AND t.response_id = r.id' . + 'WHERE questionid=' . $this->question->id() . $rsql . + ' AND t.responseid = r.id' . ' AND u.id = r.userid ' . 'ORDER BY u.lastname, u.firstname, r.submitted'; } @@ -340,15 +342,15 @@ public function get_results_tags($weights, $participants, $respondents, $showtot // 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'; + global $CFG, $SESSION, $DB; + $viewsingleresponse = $this->canviewsingleresponse; + $nonanonymous = $this->displayrespondenttype !== '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 . + $url = $CFG->wwwroot . '/mod/questionnaire/report.php?action=vresp&sid=' . $this->displaysurveysid . '¤tgroupid=' . $currentgroupid; } $users = []; @@ -423,7 +425,7 @@ public function get_results_tags($weights, $participants, $respondents, $showtot $response = new \stdClass(); $response->respondent = $straverage; $avg = $sum / $nbresponses; - $response->text = sprintf('%.' . $this->question->precise . 'f', $avg); + $response->text = sprintf('%.' . $this->question->precise() . 'f', $avg); $response->evencolor = $evencolor; $pagetags->responses[] = (object) ['response' => $response]; $evencolor = !$evencolor; diff --git a/classes/responsetype/multiple.php b/classes/local/response/multiple.php similarity index 79% rename from classes/responsetype/multiple.php rename to classes/local/response/multiple.php index a479beb74..c38e8d7a6 100644 --- a/classes/responsetype/multiple.php +++ b/classes/local/response/multiple.php @@ -14,7 +14,7 @@ // You should have received a copy of the GNU General Public License // along with Moodle. If not, see . -namespace mod_questionnaire\responsetype; +namespace mod_questionnaire\local\response; /** * Class for multiple response types. @@ -26,36 +26,45 @@ */ class multiple extends single { /** - * The only differences between multuple and single responses are the + * The only differences between multiple and single responses are the * response table and the insert logic. */ public static function response_table() { return 'questionnaire_resp_multiple'; } + /** + * Return the primary response record class for multiple-choice responses. + * + * @return \core\persistent + */ + protected function make_primary_record(): \core\persistent { + return new \mod_questionnaire\local\db\response_multiple_record(); + } + /** * 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. + * @param \mod_questionnaire\local\question\question $question + * @return array \mod_questionnaire\local\response\answer\answer An array of answer objects. * @throws \coding_exception */ public static function answers_from_webform($responsedata, $question) { $answers = []; - if (isset($responsedata->{'q' . $question->id})) { - foreach ($responsedata->{'q' . $question->id} as $cid => $cvalue) { + if (isset($responsedata->{'q' . $question->id()})) { + foreach ($responsedata->{'q' . $question->id()} as $cid => $cvalue) { $cid = clean_param($cid, PARAM_CLEAN); if (isset($question->choices[$cid])) { $record = new \stdClass(); $record->responseid = $responsedata->rid; - $record->questionid = $question->id; + $record->questionid = $question->id(); $record->choiceid = $cid; // If this choice is an "other" choice, look for the added input. if ($question->choices[$cid]->is_other_choice()) { - $cname = \mod_questionnaire\question\choice::id_other_choice_name($cid); - $record->value = isset($responsedata->{'q' . $question->id}[$cname]) ? - $responsedata->{'q' . $question->id}[$cname] : ''; + $cname = \mod_questionnaire\local\question\choice::id_other_choice_name($cid); + $record->value = isset($responsedata->{'q' . $question->id()}[$cname]) ? + $responsedata->{'q' . $question->id()}[$cname] : ''; } $answers[$cid] = answer\answer::create_from_data($record); } @@ -68,23 +77,23 @@ public static function answers_from_webform($responsedata, $question) { * Provide an array of answer objects from mobile 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. + * @param \mod_questionnaire\local\question\question $question + * @return array \mod_questionnaire\local\response\answer\answer An array of answer objects. */ public static function answers_from_appdata($responsedata, $question) { // Need to override "single" class' implementation. $answers = []; - $qname = 'q' . $question->id; + $qname = 'q' . $question->id(); if (isset($responsedata->{$qname}) && !empty($responsedata->{$qname})) { foreach ($responsedata->{$qname} as $choiceid => $choicevalue) { if ($choicevalue) { $record = new \stdClass(); $record->responseid = $responsedata->rid; - $record->questionid = $question->id; + $record->questionid = $question->id(); $record->choiceid = $choiceid; // If this choice is an "other" choice, look for the added input. if (isset($question->choices[$choiceid]) && $question->choices[$choiceid]->is_other_choice()) { - $cname = \mod_questionnaire\question\choice::id_other_choice_name($choiceid); + $cname = \mod_questionnaire\local\question\choice::id_other_choice_name($choiceid); $record->value = isset($responsedata->{$qname}[$cname]) ? $responsedata->{$qname}[$cname] : ''; } else { @@ -110,10 +119,10 @@ public static function response_select($rid) { $values = []; $sql = 'SELECT a.id, q.id as qid, q.content, c.content as ccontent, c.id as cid, o.response ' . 'FROM {' . static::response_table() . '} a ' . - 'INNER JOIN {questionnaire_question} q ON a.question_id = q.id ' . - 'INNER JOIN {questionnaire_quest_choice} c ON a.choice_id = c.id ' . - 'LEFT JOIN {questionnaire_response_other} o ON a.response_id = o.response_id AND c.id = o.choice_id ' . - 'WHERE a.response_id = ? '; + 'INNER JOIN {questionnaire_question} q ON a.questionid = q.id ' . + 'INNER JOIN {questionnaire_quest_choice} c ON a.choiceid = c.id ' . + 'LEFT JOIN {questionnaire_response_other} o ON a.responseid = o.responseid AND c.id = o.choiceid ' . + 'WHERE a.responseid = ? '; $records = $DB->get_records_sql($sql, [$rid]); if (!empty($records)) { $qid = 0; @@ -133,8 +142,8 @@ public static function response_select($rid) { $newrow['responses'] = []; } $newrow['responses'][$row->cid] = $row->cid; - if (\mod_questionnaire\question\choice::content_is_other_choice($row->ccontent)) { - $newrow['responses'][\mod_questionnaire\question\choice::id_other_choice_name($row->cid)] = + if (\mod_questionnaire\local\question\choice::content_is_other_choice($row->ccontent)) { + $newrow['responses'][\mod_questionnaire\local\question\choice::id_other_choice_name($row->cid)] = $row->response; } } @@ -182,7 +191,7 @@ public function get_bulk_sql($questionnaireids, $responseid = false, $userid = f $sql .= " AND qr.questionnaireid $qsql $showcompleteonly - LEFT JOIN {questionnaire_response_other} qro ON qro.response_id = qr.id AND qro.choice_id = qrm.choice_id + LEFT JOIN {questionnaire_response_other} qro ON qro.responseid = qr.id AND qro.choiceid = qrm.choiceid LEFT JOIN {user} u ON u.id = qr.userid $groupsql "; @@ -209,14 +218,15 @@ protected function bulk_sql() { $userfields = $this->user_fields_sql(); $alias = 'qrm'; $extraselect = ''; - $extraselect .= 'qrm.choice_id, ' . $DB->sql_order_by_text('qro.response', 1000) . ' AS response, 0 AS rankvalue'; + $extraselect .= 'qrm.choiceid, ' . $DB->sql_order_by_text('qro.response', 1000) . ' AS response, 0 AS rankvalue'; return " SELECT " . $DB->sql_concat_join("'_'", ['qr.id', "'" . $this->question->helpname() . "'", $alias . '.id']) . " AS id, - qr.submitted, qr.complete, qr.grade, qr.userid, $userfields, qr.id AS rid, $alias.question_id, + qr.submitted, qr.complete, qr.grade, qr.userid, $userfields, qr.id AS rid, + $alias.questionid AS questionid, $extraselect FROM {questionnaire_response} qr - JOIN {" . static::response_table() . "} $alias ON $alias.response_id = qr.id + JOIN {" . static::response_table() . "} $alias ON $alias.responseid = qr.id "; } } diff --git a/classes/responsetype/numericaltext.php b/classes/local/response/numericaltext.php similarity index 86% rename from classes/responsetype/numericaltext.php rename to classes/local/response/numericaltext.php index d41e3a84f..e6c3c996f 100644 --- a/classes/responsetype/numericaltext.php +++ b/classes/local/response/numericaltext.php @@ -14,7 +14,7 @@ // You should have received a copy of the GNU General Public License // along with Moodle. If not, see . -namespace mod_questionnaire\responsetype; +namespace mod_questionnaire\local\response; /** * Class for numerical text response types. @@ -29,19 +29,19 @@ class numericaltext extends text { * 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. + * @param \mod_questionnaire\local\question\question $question + * @return array \mod_questionnaire\local\response\answer\answer An array of answer objects. */ public static function answers_from_webform($responsedata, $question) { $answers = []; - if (isset($responsedata->{'q' . $question->id}) && is_numeric($responsedata->{'q' . $question->id})) { - $val = $responsedata->{'q' . $question->id}; + if (isset($responsedata->{'q' . $question->id()}) && is_numeric($responsedata->{'q' . $question->id()})) { + $val = $responsedata->{'q' . $question->id()}; // Allow commas as well as points in decimal numbers. - $val = str_replace(",", ".", $responsedata->{'q' . $question->id}); + $val = str_replace(",", ".", $responsedata->{'q' . $question->id()}); $val = preg_replace("/[^0-9.\-]*(-?[0-9]*\.?[0-9]*).*/", '\1', $val); $record = new \stdClass(); $record->responseid = $responsedata->rid; - $record->questionid = $question->id; + $record->questionid = $question->id(); $record->value = $val; $answers[] = answer\answer::create_from_data($record); } diff --git a/classes/local/response/questionnaire_responses.php b/classes/local/response/questionnaire_responses.php new file mode 100644 index 000000000..6594cdd7d --- /dev/null +++ b/classes/local/response/questionnaire_responses.php @@ -0,0 +1,938 @@ +. + +namespace mod_questionnaire\local\response; + +/** + * Response handler for a specific questionnaire instance. + * + * Caches loaded responses, performs CRUD, validates response format, + * and queries the response data for the bound questionnaire. + * + * @package mod_questionnaire + * @copyright 2016 onward Mike Churchward (mike.churchward@poetopensource.org) + * @author Mike Churchward + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class questionnaire_responses { + /** @var \mod_questionnaire\questionnaire The questionnaire instance. */ + private readonly \mod_questionnaire\questionnaire $questionnaire; + + /** @var array Loaded response objects keyed by response id. */ + private array $responses = []; + + /** + * Constructor. + * @param \mod_questionnaire\questionnaire $questionnaire + */ + public function __construct(\mod_questionnaire\questionnaire $questionnaire) { + $this->questionnaire = $questionnaire; + } + + // Response loading / factory methods. + + /** + * Load all response information for the given user. + * + * @param int|null $userid + */ + public function add_user_responses($userid = null) { + global $USER; + + if (empty($this->questionnaire->id())) { + return; + } + + if ($userid === null) { + $userid = $USER->id; + } + + $responses = $this->get_responses($userid); + foreach ($responses as $response) { + $this->responses[$response->id] = + \mod_questionnaire\local\response\response::create_from_data($response); + } + } + + /** + * Load the specified response. + * + * @param int $responseid + */ + public function add_response(int $responseid) { + if (empty($this->questionnaire->id())) { + return; + } + + $this->responses[$responseid] = new \mod_questionnaire\local\response\response( + new \mod_questionnaire\local\db\response_record($responseid), + true + ); + } + + /** + * Load the response information from a submitted web form. + * + * @param \stdClass $formdata + */ + public function add_response_from_formdata(\stdClass $formdata) { + $this->responses[0] = + \mod_questionnaire\local\response\response::response_from_webform( + $formdata, + $this->questionnaire->questions() + ); + } + + /** + * Return a response object from a submitted mobile app form. + * + * @param \stdClass $appdata + * @param int $sec + * @param int $responseid The existing response id (0 for a new response). + * @return bool|\mod_questionnaire\local\response\response + */ + public function build_response_from_appdata(\stdClass $appdata, $sec = 0, int $responseid = 0) { + $questions = []; + if ($sec == 0) { + $questions = $this->questionnaire->questions(); + } else { + foreach ($this->questionnaire->questions_by_section_all()[$sec] as $question) { + $questions[$question->id()] = $question; + } + } + return \mod_questionnaire\local\response\response::response_from_appdata( + $this->questionnaire->id(), + $responseid, + $appdata, + $questions + ); + } + + // Response accessors. + + /** + * Return a single loaded response object by response id, or null if not loaded. + * @param int $rid + * @return \mod_questionnaire\local\response\response|null + */ + public function get_response(int $rid) { + return $this->responses[$rid] ?? null; + } + + /** + * Return all currently loaded response objects (keyed by response id). + * @return array + */ + public function get_loaded_responses(): array { + return $this->responses; + } + + // Response querying. + + /** + * Get the requested responses for this questionnaire. + * + * @param int|bool $userid + * @param int $groupid + * @return array + */ + public function get_responses($userid = false, $groupid = 0) { + global $DB; + + $params = []; + $groupsql = ''; + $groupcnd = ''; + if ($groupid != 0) { + $groupsql = 'INNER JOIN {groups_members} gm ON r.userid = gm.userid '; + $groupcnd = ' AND gm.groupid = :groupid '; + $params['groupid'] = $groupid; + } + + if ($this->questionnaire->survey_is_public_master()) { + $sql = 'SELECT r.* ' . + 'FROM {questionnaire_response} r ' . + '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; + $params['surveyid'] = $this->questionnaire->surveyid(); + $params['status'] = 'y'; + } else { + $sql = 'SELECT r.* ' . + 'FROM {questionnaire_response} r ' . + $groupsql . + 'WHERE r.questionnaireid = :questionnaireid' . $groupcnd; + $params['questionnaireid'] = $this->questionnaire->id(); + } + if ($userid) { + $sql .= ' AND r.userid = :userid'; + $params['userid'] = $userid; + } + + $status = optional_param('responsestats', '0', PARAM_ALPHANUM); + if ($status) { + $status = ($status === 'n') ? 'n' : 'y'; + $sql .= ' AND r.complete = :status'; + $params['status'] = $status; + } + $sql .= ' ORDER BY r.id'; + return $DB->get_records_sql($sql, $params) ?? []; + } + + /** + * True if the specified user has a saved (incomplete) response. + * @param int $userid + * @return bool + */ + public function user_has_saved_response($userid) { + global $DB; + + return $DB->record_exists( + 'questionnaire_response', + ['questionnaireid' => $this->questionnaire->id(), 'userid' => $userid, 'complete' => 'n'] + ); + } + + /** + * Get all survey responses in one go (UNION ALL across all question types). + * + * @param string $rid + * @param string $userid + * @param bool $groupid + * @param int $showincompletes + * @return array + */ + public function get_survey_all_responses($rid = '', $userid = '', $groupid = false, $showincompletes = 0) { + global $DB; + $uniquetypes = $this->survey_questiontypes(true); + $allresponsessql = ""; + $allresponsesparams = []; + + if ($this->questionnaire->survey_is_public_master()) { + $qids = array_keys($DB->get_records('questionnaire', ['sid' => $this->questionnaire->surveyid()], 'id') ?? []); + } else { + $qids = $this->questionnaire->id(); + } + + foreach ($uniquetypes as $type) { + $question = \mod_questionnaire\local\question\question::question_builder($type); + if (!isset($question->responsetype)) { + continue; + } + $allresponsessql .= $allresponsessql == '' ? '' : ' UNION ALL '; + [$sql, $params] = $question->responsetype->get_bulk_sql($qids, $rid, $userid, $groupid, $showincompletes); + $allresponsesparams = array_merge($allresponsesparams, $params); + $allresponsessql .= $sql; + } + + if (empty($allresponsessql)) { + return []; + } + $allresponsessql .= " ORDER BY usrid, id"; + $allresponses = $DB->get_recordset_sql($allresponsessql, $allresponsesparams); + return $allresponses ?? []; + } + + /** + * Get the answers for all response types for a given response id. + * @param int $rid + * @return array + */ + public function response_select($rid) { + $values = \mod_questionnaire\local\response\boolean::response_select($rid); + $values += \mod_questionnaire\local\response\single::response_select($rid); + $values += \mod_questionnaire\local\response\multiple::response_select($rid); + $values += \mod_questionnaire\local\response\rank::response_select($rid); + $values += \mod_questionnaire\local\response\text::response_select($rid); + $values += \mod_questionnaire\local\response\date::response_select($rid); + return $values; + } + + /** + * Return the section number of the last answered section in a response. + * + * @param int $rid + * @return int + */ + public function response_select_max_sec(int $rid): int { + $surveyid = $this->questionnaire->surveyid(); + $pos = $this->response_select_max_pos($rid); + return \mod_questionnaire\local\db\question_record::count_active_before_position( + $surveyid, + QUESPAGEBREAK, + $pos + ) + 1; + } + + /** + * Return the position of the last answered question in a response. + * + * @param int $rid + * @return int + */ + private function response_select_max_pos(int $rid): int { + global $DB; + $surveyid = $this->questionnaire->surveyid(); + $max = 0; + foreach ( + [ + 'response_bool', + 'resp_single', + 'resp_multiple', + 'response_rank', + 'response_text', + 'response_other', + 'response_date', + ] as $tbl + ) { + $sql = 'SELECT MAX(q.position) as num FROM {questionnaire_' . $tbl . '} a, {questionnaire_question} q ' . + 'WHERE a.responseid = ? AND ' . + 'q.id = a.questionid AND ' . + 'q.surveyid = ? AND ' . + 'q.deleted IS NULL'; + if ($record = $DB->get_record_sql($sql, [$rid, $surveyid])) { + $newmax = (int)$record->num; + if ($newmax > $max) { + $max = $newmax; + } + } + } + return $max; + } + + /** + * Construct the response data for a given response and return a structured export. + * @param int $rid + * @return array + */ + public function get_structured_response($rid) { + $this->add_response($rid); + return $this->get_full_submission_for_export($rid); + } + + /** + * Return an array containing all the questions and answers for a specific submission. + * @param int $rid + * @return array + */ + public function get_full_submission_for_export($rid) { + if (!isset($this->responses[$rid])) { + $this->add_response($rid); + } + + $exportstructure = []; + foreach ($this->questionnaire->questions() as $question) { + $rqid = 'q' . $question->id(); + $response = new \stdClass(); + $response->questionname = $question->position() . '. ' . $question->name(); + $response->questiontext = $question->content(); + $response->answers = []; + if ($question->typeid() == 8) { + $choices = []; + $cids = []; + foreach ($question->choices as $cid => $choice) { + if (!empty($choice->value) && (strpos($choice->content, '=') !== false)) { + $choices[$choice->value] = substr($choice->content, (strpos($choice->content, '=') + 1)); + } else { + $cids[$rqid . '_' . $cid] = $choice->content; + } + } + if (isset($this->responses[$rid]->answers[$question->id()])) { + foreach ($cids as $rqid => $choice) { + $cid = substr($rqid, (strpos($rqid, '_') + 1)); + if (isset($this->responses[$rid]->answers[$question->id()][$cid])) { + if ( + isset($question->choices[$cid]) && + isset($choices[$this->responses[$rid]->answers[$question->id()][$cid]->value]) + ) { + $rating = $choices[$this->responses[$rid]->answers[$question->id()][$cid]->value]; + } else { + $rating = $this->responses[$rid]->answers[$question->id()][$cid]->value; + } + $response->answers[] = $question->choices[$cid]->content . ' = ' . $rating; + } + } + } + } else if ($question->has_choices()) { + $answertext = ''; + if (isset($this->responses[$rid]->answers[$question->id()])) { + $i = 0; + foreach ($this->responses[$rid]->answers[$question->id()] as $answer) { + if ($i > 0) { + $answertext .= '; '; + } + if ($question->choices[$answer->choiceid]->is_other_choice()) { + $answertext .= $answer->value; + } else { + $answertext .= $question->choices[$answer->choiceid]->content; + } + $i++; + } + } + $response->answers[] = $answertext; + } else if (isset($this->responses[$rid]->answers[$question->id()])) { + $response->answers[] = $this->responses[$rid]->answers[$question->id()][0]->value; + } + $exportstructure[] = $response; + } + + return $exportstructure; + } + + // Response saving / committing. + + /** + * Insert the provided response. + * @param object $responsedata + * @param int $userid + * @param bool $resume + * @return bool|int + */ + public function response_insert($responsedata, $userid, $resume = false) { + if (empty($responsedata->rid)) { + $response = \mod_questionnaire\local\response\response::create($this->questionnaire->id(), $userid); + $responsedata->rid = $response->id(); + } else { + $response = new \mod_questionnaire\local\response\response( + new \mod_questionnaire\local\db\response_record($responsedata->rid) + ); + $response->touch(); + } + if ($resume) { + $context = \context_module::instance($this->questionnaire->coursemodule()->id); + $anonymous = $this->questionnaire->is_anonymous(); + $params = [ + 'context' => $context, + 'courseid' => $this->questionnaire->course()->id, + 'relateduserid' => $userid, + 'anonymous' => $anonymous, + 'other' => ['questionnaireid' => $this->questionnaire->id()], + ]; + $event = \mod_questionnaire\event\attempt_saved::create($params); + $event->trigger(); + } + + if (!isset($responsedata->sec)) { + $responsedata->sec = 1; + } + $questionsbysec = $this->questionnaire->questions_by_section_all(); + if (!empty($questionsbysec[$responsedata->sec])) { + foreach ($questionsbysec[$responsedata->sec] as $question) { + $question->insert_response($responsedata); + } + } + return $responsedata->rid; + } + + /** + * Commit the specified response (mark it complete and record submission time). + * @param int $rid + * @return bool + */ + public function response_commit($rid) { + $grade = $this->questionnaire->grade() < 0 ? 1 : $this->questionnaire->grade(); + $response = new \mod_questionnaire\local\response\response( + new \mod_questionnaire\local\db\response_record($rid) + ); + $response->commit($grade); + return true; + } + + /** + * Delete the specified response and insert a new one. + * @param int $rid + * @param int $sec + * @param int $quser + * @return int + */ + public function delete_insert_response($rid, $sec, $quser): int { + $this->response_delete($rid, $sec); + return $this->response_insert((object)['sec' => $sec, 'rid' => $rid], $quser); + } + + /** + * Commit the response and update grades and completion state. + * @param int $rid + * @param int $quser + */ + public function commit_submission_response($rid, $quser) { + $this->response_commit($rid); + + \mod_questionnaire\gradebook::update_grades($this->questionnaire, $quser); + + $completion = new \completion_info($this->questionnaire->course()); + if ($completion->is_enabled($this->questionnaire->coursemodule()) && $this->questionnaire->completionsubmit()) { + $completion->update_state($this->questionnaire->coursemodule(), COMPLETION_COMPLETE); + } + $context = \context_module::instance($this->questionnaire->coursemodule()->id); + $anonymous = $this->questionnaire->is_anonymous(); + $params = [ + 'context' => $context, + 'courseid' => $this->questionnaire->course()->id, + 'relateduserid' => $quser, + 'anonymous' => $anonymous, + 'other' => ['questionnaireid' => $this->questionnaire->id()], + ]; + $event = \mod_questionnaire\event\attempt_submitted::create($params); + $event->trigger(); + } + + // Response deletion. + + /** + * Delete the specified response's answer data. + * @param int $rid + * @param int|null $sec If provided, only delete answers in that section. + */ + public function response_delete($rid, $sec = null) { + global $DB; + + if (empty($rid)) { + return; + } + + if ($sec != null) { + if ($sec < 1) { + return; + } + + $questionsbysec = $this->questionnaire->questions_by_section_all(); + $numsections = count($questionsbysec); + $sec = min($numsections, $sec); + + $qids = []; + foreach ($questionsbysec[$sec] as $question) { + $qids[] = $question->id(); + } + if (empty($qids)) { + return; + } else { + [$qsql, $params] = $DB->get_in_or_equal($qids); + $qsql = ' AND questionid ' . $qsql; + } + } else { + $qsql = ''; + $params = []; + } + + $select = 'responseid = \'' . $rid . '\' ' . $qsql; + foreach ( + [ + 'response_bool', + 'resp_single', + 'resp_multiple', + 'response_rank', + 'response_text', + 'response_other', + 'response_date', + ] as $tbl + ) { + $DB->delete_records_select('questionnaire_' . $tbl, $select, $params); + } + } + + // Response validation. + + /** + * Check that the response is complete and correctly formatted. + * @param int $section + * @param object $formdata + * @param bool $checkmissing + * @param bool $checkwrongformat + * @param array $notifyquestions Optional external question objects to receive notifications (keyed by question id). + * When provided, notifications are added to these objects instead of the internal question objects. + * @return string Error message, or empty string if valid. + */ + public function response_check_format( + $section, + $formdata, + $checkmissing = true, + $checkwrongformat = true, + array $notifyquestions = [] + ) { + $missing = 0; + $strmissing = ''; + $wrongformat = 0; + $strwrongformat = ''; + $i = 1; + $questionsbysec = $this->questionnaire->questions_by_section_all(); + for ($j = 2; $j <= $section; $j++) { + foreach ($questionsbysec[$j - 1] as $question) { + if ($question->typeid() < QUESPAGEBREAK) { + $i++; + } + } + } + $qnum = $i - 1; + + if (key_exists($section, $questionsbysec)) { + foreach ($questionsbysec[$section] as $question) { + if ($question->is_numbered()) { + $qnum++; + } + if (!$question->response_complete($formdata)) { + $missing++; + $strnum = get_string('num', 'questionnaire') . $qnum . '. '; + $strmissing .= $strnum; + $strnoti = get_string('missingquestion', 'questionnaire') . $strnum; + $notifytarget = $notifyquestions[$question->id()] ?? $question; + $notifytarget->add_notification($strnoti); + } + if (!$question->response_valid($formdata)) { + $wrongformat++; + $strwrongformat .= get_string('num', 'questionnaire') . $qnum . '. '; + } + } + } + $message = ''; + $nonumbering = false; + if (!$this->questionnaire->questions_autonumbered()) { + $nonumbering = true; + } + if ($checkmissing && $missing) { + if ($nonumbering) { + $strmissing = ''; + } + if ($missing == 1) { + $message = get_string('missingquestion', 'questionnaire') . $strmissing; + } else { + $message = get_string('missingquestions', 'questionnaire') . $strmissing; + } + if ($wrongformat) { + $message .= '
'; + } + } + if ($checkwrongformat && $wrongformat) { + if ($nonumbering) { + $message .= get_string('wronganswers', 'questionnaire'); + } else { + if ($wrongformat == 1) { + $message .= get_string('wrongformat', 'questionnaire') . $strwrongformat; + } else { + $message .= get_string('wrongformats', 'questionnaire') . $strwrongformat; + } + } + } + return $message; + } + + // Static helpers — usable without a questionnaire instance. + + /** + * True if the given user has at least one complete response for this questionnaire. + * + * @param int $userid + * @return bool + */ + public function response_exists(int $userid): bool { + return \mod_questionnaire\local\db\response_record::user_has_complete_response( + $this->questionnaire->id(), + $userid + ); + } + + /** + * Count the number of distinct users who have a response to this questionnaire, + * optionally restricted to members of one or more groups. + * + * Counts both saved (in-progress) and submitted responses. Callers that need + * only submitted responses should use questionnaire::count_submissions(). + * + * @param int[] $groupids Group ids to restrict by; empty means no group filter. + * @return int + */ + public function count_distinct_responders(array $groupids = []): int { + global $DB; + + $params = ['questionnaireid' => $this->questionnaire->id()]; + + if (empty($groupids)) { + return $DB->count_records_select( + 'questionnaire_response', + 'questionnaireid = :questionnaireid', + $params, + 'COUNT(DISTINCT userid)' + ); + } + + [$gsql, $gparams] = $DB->get_in_or_equal($groupids, SQL_PARAMS_NAMED); + $sql = "SELECT COUNT(DISTINCT qr.userid) + FROM {questionnaire_response} qr + JOIN {groups_members} gm ON qr.userid = gm.userid + WHERE qr.questionnaireid = :questionnaireid + AND gm.groupid $gsql"; + return $DB->count_records_sql($sql, $params + $gparams); + } + + /** + * Get all responses for a given questionnaire instance id and user, without needing an instance. + * @param int $instanceid The questionnaire.id value. + * @param int $userid + * @param bool $complete True = only complete responses, false = all responses. + * @return array + */ + public static function get_user_responses_for_instance(int $instanceid, int $userid, bool $complete = true): array { + global $DB; + $andcomplete = $complete ? " AND complete = 'y' " : ''; + return $DB->get_records_sql( + "SELECT * FROM {questionnaire_response} + WHERE questionnaireid = ? + AND userid = ? + " . $andcomplete . " + ORDER BY submitted ASC", + [$instanceid, $userid] + ) ?? []; + } + + /** + * Delete all response data for a given question id, without needing a questionnaire instance. + * @param int $qid + * @return bool + */ + public static function delete_responses_for_question(int $qid): bool { + global $DB; + $DB->delete_records('questionnaire_response_bool', ['questionid' => $qid]); + $DB->delete_records('questionnaire_response_date', ['questionid' => $qid]); + $DB->delete_records('questionnaire_resp_multiple', ['questionid' => $qid]); + $DB->delete_records('questionnaire_response_other', ['questionid' => $qid]); + $DB->delete_records('questionnaire_response_rank', ['questionid' => $qid]); + $DB->delete_records('questionnaire_resp_single', ['questionid' => $qid]); + $DB->delete_records('questionnaire_response_text', ['questionid' => $qid]); + $DB->delete_records('questionnaire_response_file', ['questionid' => $qid]); + return true; + } + + // Functions migrated from locallib.php. + + /** + * Get all responses for the given user and questionnaire. + * @param int $userid + * @param bool $complete True = only complete responses, false = all responses. + * @return array + */ + public function get_user_responses($userid, $complete = true) { + global $DB; + $andcomplete = ''; + if ($complete) { + $andcomplete = " AND complete = 'y' "; + } + return $DB->get_records_sql("SELECT * + FROM {questionnaire_response} + WHERE questionnaireid = ? + AND userid = ? + " . $andcomplete . " + ORDER BY submitted ASC ", [$this->questionnaire->id(), $userid]) ?? []; + } + + /** + * Delete a single response and update completion state. + * @param \stdClass $response The response record (must have ->id and ->userid). + * @return bool + */ + public function delete_response(\stdClass $response) { + global $DB; + $status = true; + $rid = $response->id; + + $DB->delete_records('questionnaire_response_bool', ['responseid' => $rid]); + $DB->delete_records('questionnaire_response_date', ['responseid' => $rid]); + $DB->delete_records('questionnaire_resp_multiple', ['responseid' => $rid]); + $DB->delete_records('questionnaire_response_other', ['responseid' => $rid]); + $DB->delete_records('questionnaire_response_rank', ['responseid' => $rid]); + $DB->delete_records('questionnaire_resp_single', ['responseid' => $rid]); + $DB->delete_records('questionnaire_response_text', ['responseid' => $rid]); + $DB->delete_records('questionnaire_response_file', ['responseid' => $rid]); + + $status = $status && $DB->delete_records('questionnaire_response', ['id' => $rid]); + + if ($status) { + $cm = $this->questionnaire->coursemodule(); + $completion = new \completion_info($this->questionnaire->course()); + if ( + $completion->is_enabled($cm) == COMPLETION_TRACKING_AUTOMATIC && + $this->questionnaire->completionsubmit() + ) { + $completion->update_state($cm, COMPLETION_INCOMPLETE, $response->userid); + } + } + + return $status; + } + + /** + * Delete all response data for a given question id. + * @param int $qid + * @return bool + */ + public function delete_responses($qid) { + global $DB; + + $DB->delete_records('questionnaire_response_bool', ['questionid' => $qid]); + $DB->delete_records('questionnaire_response_date', ['questionid' => $qid]); + $DB->delete_records('questionnaire_resp_multiple', ['questionid' => $qid]); + $DB->delete_records('questionnaire_response_other', ['questionid' => $qid]); + $DB->delete_records('questionnaire_response_rank', ['questionid' => $qid]); + $DB->delete_records('questionnaire_resp_single', ['questionid' => $qid]); + $DB->delete_records('questionnaire_response_text', ['questionid' => $qid]); + $DB->delete_records('questionnaire_response_file', ['questionid' => $qid]); + + return true; + } + + // Private helpers. + + /** + * Return the unique question type IDs (and optionally deduplicated by response table) + * for the questions in this questionnaire. + * + * @param bool $uniquebytable + * @return array + */ + /** + * Return user ids of enrolled users who have not completed the given questionnaire instance. + * + * @param \stdClass|\cm_info $cm Course-module record (needs id and instance fields). + * @param int $sid Survey id (unused — retained for compat; instance check uses cm->instance). + * @param int|false $group Group id to restrict to, or false for all groups. + * @param string $sort SQL sort fragment for get_enrolled_users(). + * @param int|false $startpage First slice offset for paging, or false to skip. + * @param int|false $pagecount Slice length for paging, or false to skip. + * @return array|false Array of user ids, or false if no eligible users exist. + */ + public static function get_incomplete_users( + $cm, + int $sid, + $group = false, + string $sort = '', + $startpage = false, + $pagecount = false + ) { + global $DB; + + $context = \context_module::instance($cm->id); + + $cap = 'mod/questionnaire:submit'; + $fields = 'u.id, u.username'; + if (!$allusers = get_enrolled_users($context, $cap, $group, $fields, $sort)) { + return false; + } + $allusers = array_keys($allusers); + + $params = ['questionnaireid' => $cm->instance, 'complete' => 'y']; + $sql = "SELECT userid FROM {questionnaire_response} + WHERE questionnaireid = :questionnaireid AND complete = :complete + GROUP BY userid"; + if (!$completedusers = $DB->get_records_sql($sql, $params)) { + return $allusers; + } + $completedusers = array_keys($completedusers); + $allusers = array_diff($allusers, $completedusers); + if (($startpage !== false) && ($pagecount !== false)) { + $allusers = array_slice($allusers, $startpage, $pagecount); + } + return $allusers; + } + + /** + * Count the number of response records for a specific question. + * + * @param int $qid Question id. + * @param int $qtype Question type id. + * @return int Count of response records, or 0 if none/not applicable. + */ + public static function count_for_question(int $qid, int $qtype): int { + global $DB; + + if ($qtype == \mod_questionnaire\local\question_type::QUESSECTIONTEXT) { + return 0; + } + $responsetable = $DB->get_field( + 'questionnaire_question_type', + 'responsetable', + ['typeid' => $qtype] + ); + if (empty($responsetable)) { + return 0; + } + return $DB->count_records('questionnaire_' . $responsetable, ['questionid' => $qid]); + } + + /** + * Delete all responses that have passed the configured retention period for their questionnaire. + * + * Called from the cleanup scheduled task. + */ + public static function delete_old_responses(): void { + global $DB; + + $currenttime = time(); + $sql = "SELECT qr.id + FROM {questionnaire} q + JOIN {questionnaire_response} qr ON qr.questionnaireid = q.id AND qr.complete = 'y' + WHERE q.removeafter <> 0 AND (q.removeafter < :currenttime - qr.submitted)"; + $oldresponses = $DB->get_records_sql($sql, ['currenttime' => $currenttime]); + + if (!empty($oldresponses)) { + try { + $oldresponsesid = array_keys($oldresponses); + $count = count($oldresponsesid); + if (!PHPUNIT_TEST) { + mtrace("\nBeginning deleting $count old responses requests"); + } + $responsetables = [ + 'questionnaire_response_bool', 'questionnaire_response_date', + 'questionnaire_resp_multiple', 'questionnaire_response_other', + 'questionnaire_response_rank', 'questionnaire_resp_single', + 'questionnaire_response_text', + ]; + foreach ($responsetables as $tablename) { + $DB->delete_records_list($tablename, 'responseid', $oldresponsesid); + } + $DB->delete_records_list('questionnaire_response', 'id', $oldresponsesid); + if (!PHPUNIT_TEST) { + mtrace("\nCompleted deleting $count old responses requests"); + } + } catch (\dml_exception $ex) { + debugging('Error: ' . $ex->getMessage(), DEBUG_DEVELOPER); + } + } + } + + /** + * Return the question types used in this survey's questions. + * @param bool $uniquebytable + * @return array + */ + private function survey_questiontypes($uniquebytable = false) { + $uniquetypes = []; + $uniquetables = []; + + foreach ($this->questionnaire->questions() as $question) { + $type = $question->typeid(); + $responsetable = $question->responsetable(); + if (!$uniquebytable || !in_array($responsetable, $uniquetables)) { + if (!in_array($type, $uniquetypes)) { + $uniquetypes[] = $type; + } + if (!in_array($responsetable, $uniquetables)) { + $uniquetables[] = $responsetable; + } + } + } + + return $uniquetypes; + } +} diff --git a/classes/responsetype/rank.php b/classes/local/response/rank.php similarity index 86% rename from classes/responsetype/rank.php rename to classes/local/response/rank.php index b9d9b4162..628cca7c5 100644 --- a/classes/responsetype/rank.php +++ b/classes/local/response/rank.php @@ -14,10 +14,10 @@ // You should have received a copy of the GNU General Public License // along with Moodle. If not, see . -namespace mod_questionnaire\responsetype; +namespace mod_questionnaire\local\response; -use Composer\Package\Package; use mod_questionnaire\db\bulk_sql_config; +use mod_questionnaire\local\question\question; /** * Class for rank responses. @@ -47,15 +47,15 @@ public static function response_table() { * 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. + * @param \mod_questionnaire\local\question\question $question + * @return array \mod_questionnaire\local\response\answer\answer An array of answer objects. * @throws \coding_exception */ public static function answers_from_webform($responsedata, $question) { $answers = []; foreach ($question->choices as $cid => $choice) { - $other = isset($responsedata->{'q' . $question->id . '_' . $cid}) ? - $responsedata->{'q' . $question->id . '_' . $cid} : null; + $other = isset($responsedata->{'q' . $question->id() . '_' . $cid}) ? + $responsedata->{'q' . $question->id() . '_' . $cid} : null; // Choice not set or not answered. if (!isset($other) || $other == '') { continue; @@ -67,7 +67,7 @@ public static function answers_from_webform($responsedata, $question) { } $record = new \stdClass(); $record->responseid = $responsedata->rid; - $record->questionid = $question->id; + $record->questionid = $question->id(); $record->choiceid = $cid; $record->value = $rank; $answers[$cid] = answer\answer::create_from_data($record); @@ -79,17 +79,17 @@ public static function answers_from_webform($responsedata, $question) { * Provide an array of answer objects from mobile 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. + * @param \mod_questionnaire\local\question\question $question + * @return array \mod_questionnaire\local\response\answer\answer An array of answer objects. */ public static function answers_from_appdata($responsedata, $question) { $answers = []; - if (isset($responsedata->{'q' . $question->id}) && !empty($responsedata->{'q' . $question->id})) { - foreach ($responsedata->{'q' . $question->id} as $choiceid => $choicevalue) { + if (isset($responsedata->{'q' . $question->id()}) && !empty($responsedata->{'q' . $question->id()})) { + foreach ($responsedata->{'q' . $question->id()} as $choiceid => $choicevalue) { if (isset($question->choices[$choiceid])) { $record = new \stdClass(); $record->responseid = $responsedata->rid; - $record->questionid = $question->id; + $record->questionid = $question->id(); $record->choiceid = $choiceid; if (!empty($question->nameddegrees)) { // If using named degrees, the app returns the label string. Find the value. @@ -113,32 +113,36 @@ public static function answers_from_appdata($responsedata, $question) { * @return int|bool - on error the subtype should call set_error and return false. */ 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]); + if (!$responsedata instanceof \mod_questionnaire\local\response\response) { + $response = \mod_questionnaire\local\response\response::response_from_webform( + $responsedata, + [$this->question] + ); } else { $response = $responsedata; } $resid = false; - if (isset($response->answers[$this->question->id])) { - foreach ($response->answers[$this->question->id] as $answer) { + if (isset($response->answers[$this->question->id()])) { + foreach ($response->answers[$this->question->id()] as $answer) { // Record the choice selection. - $record = new \stdClass(); - $record->response_id = $response->id; - $record->question_id = $this->question->id; - $record->choice_id = $answer->choiceid; - $record->rankvalue = $answer->value; - $resid = $DB->insert_record(static::response_table(), $record); + $rec = new \mod_questionnaire\local\db\response_rank_record(); + $rec->set('responseid', $response->id()); + $rec->set('questionid', $this->question->id()); + $rec->set('choiceid', $answer->choiceid); + $rec->set('rankvalue', $answer->value); + $rec->create(); + $resid = $rec->get('id'); + // The "other" text is read directly from $responsedata here (not from the + // parsed $response->answers) — pre-existing inconsistency, not fixed in Phase 23. if (isset($responsedata->{$answer->choiceid . '_qother'})) { - $otherrecord = new \stdClass(); - $otherrecord->response_id = $response->id; - $otherrecord->question_id = $this->question->id; - $otherrecord->choice_id = $answer->choiceid; - $otherrecord->response = $responsedata->{$answer->choiceid . '_qother'}; - $DB->insert_record('questionnaire_response_other', $otherrecord); + $otherrec = new \mod_questionnaire\local\db\response_other_record(); + $otherrec->set('responseid', $response->id()); + $otherrec->set('questionid', $this->question->id()); + $otherrec->set('choiceid', $answer->choiceid); + $otherrec->set('response', $responsedata->{$answer->choiceid . '_qother'}); + $otherrec->create(); } } } @@ -161,23 +165,23 @@ public function get_results($rids = false, $anonymous = false) { $params = []; if (!empty($rids)) { [$rsql, $params] = $DB->get_in_or_equal($rids); - $rsql = ' AND response_id ' . $rsql; + $rsql = ' AND responseid ' . $rsql; } // Get other choices. $otherrecs = $this->get_other_choice($rsql, $params); - $select = 'question_id=' . $this->question->id . ' ORDER BY id ASC'; + $select = 'questionid=' . $this->question->id() . ' ORDER BY id ASC'; if ($rows = $DB->get_records_select('questionnaire_quest_choice', $select)) { foreach ($rows as $row) { $nbna = $DB->count_records( static::response_table(), [ - 'question_id' => $this->question->id, - 'choice_id' => $row->id, + 'questionid' => $this->question->id(), + 'choiceid' => $row->id, 'rankvalue' => '-1', ] ); - if (\mod_questionnaire\question\choice::content_is_other_choice($row->content) && !empty($otherrecs)) { + if (\mod_questionnaire\local\question\choice::content_is_other_choice($row->content) && !empty($otherrecs)) { foreach (array_keys($otherrecs) as $key) { $this->counts[$key] = new \stdClass(); $this->counts[$key]->nbna = $nbna; @@ -196,14 +200,14 @@ public function get_results($rids = false, $anonymous = false) { $rankvalue = array_flip(array_keys($this->question->nameddegrees)); } - $isrestricted = ($this->question->length < count($this->question->choices)) && $this->question->no_duplicate_choices(); + $isrestricted = ($this->question->length() < count($this->question->choices)) && $this->question->no_duplicate_choices(); // Usual case. if (!$isrestricted) { if (!empty($rankvalue)) { $sql = "SELECT r.id, c.content, r.rankvalue, c.id AS choiceid FROM {questionnaire_quest_choice} c, {" . static::response_table() . "} r - WHERE r.choice_id = c.id - AND c.question_id = " . $this->question->id . " + WHERE r.choiceid = c.id + AND c.questionid = " . $this->question->id() . " AND r.rankvalue >= 0{$rsql} ORDER BY choiceid"; $results = $DB->get_records_sql($sql, $params); @@ -222,13 +226,13 @@ public function get_results($rids = false, $anonymous = false) { $sql = "SELECT c.id, c.content, a.average, a.num FROM {questionnaire_quest_choice} c INNER JOIN - (SELECT c2.id, AVG(a2.rankvalue) AS average, COUNT(a2.response_id) AS num + (SELECT c2.id, AVG(a2.rankvalue) AS average, COUNT(a2.responseid) AS num FROM {questionnaire_quest_choice} c2, {" . static::response_table() . "} a2 - WHERE c2.question_id = ? AND a2.question_id = ? AND a2.choice_id = c2.id + WHERE c2.questionid = ? AND a2.questionid = ? AND a2.choiceid = c2.id AND a2.rankvalue >= 0 AND c2.content NOT LIKE '!other%'{$rsql} GROUP BY c2.id) a ON a.id = c.id ORDER BY c.id"; - $results = $DB->get_records_sql($sql, array_merge([$this->question->id, $this->question->id], $params)); + $results = $DB->get_records_sql($sql, array_merge([$this->question->id(), $this->question->id()], $params)); // Handle 'other...'. if ($otherrecs) { @@ -262,13 +266,13 @@ public function get_results($rids = false, $anonymous = false) { $sql = "SELECT c.id, c.content, a.sum, a.num FROM {questionnaire_quest_choice} c INNER JOIN - (SELECT c2.id, SUM(a2.rankvalue) AS sum, COUNT(a2.response_id) AS num + (SELECT c2.id, SUM(a2.rankvalue) AS sum, COUNT(a2.responseid) AS num FROM {questionnaire_quest_choice} c2, {" . static::response_table() . "} a2 - WHERE c2.question_id = ? AND a2.question_id = ? AND a2.choice_id = c2.id + WHERE c2.questionid = ? AND a2.questionid = ? AND a2.choiceid = c2.id AND a2.rankvalue >= 0 AND c2.content NOT LIKE '!other%'{$rsql} GROUP BY c2.id) a ON a.id = c.id"; - $results = $DB->get_records_sql($sql, array_merge([$this->question->id, $this->question->id], $params)); + $results = $DB->get_records_sql($sql, array_merge([$this->question->id(), $this->question->id()], $params)); if ($otherrecs) { $i = 1; @@ -285,8 +289,9 @@ public function get_results($rids = false, $anonymous = false) { // Formula to calculate the best ranking order. $nbresponses = count($rids); foreach ($results as $key => $result) { - if (isset($this->length)) { - $result->average = ($result->sum + ($nbresponses - $result->num) * ($this->length + 1)) / $nbresponses; + if ($this->question->length() > 0) { + $result->average = ($result->sum + ($nbresponses - $result->num) * + ($this->question->length() + 1)) / $nbresponses; } else { $result->average = ($result->sum + ($nbresponses - $result->num) * 1 ) / $nbresponses; } @@ -306,20 +311,20 @@ public function get_results($rids = false, $anonymous = false) { */ public function get_other_choice(string $rsql, array $params): array { global $DB; - $osql = "SELECT ro.response, AVG(a.rankvalue) AS average, COUNT(a.response_id) AS num, c.id as cid + $osql = "SELECT ro.response, AVG(a.rankvalue) AS average, COUNT(a.responseid) AS num, c.id as cid FROM {questionnaire_quest_choice} c - INNER JOIN {" . static::response_table() . "} a ON a.choice_id = c.id + INNER JOIN {" . static::response_table() . "} a ON a.choiceid = c.id AND a.rankvalue >= 0 - AND a.question_id = c.question_id{$rsql} - INNER JOIN {questionnaire_response_other} ro ON ro.choice_id = c.id - AND ro.response_id = a.response_id - AND ro.question_id = c.question_id - WHERE c.question_id = ? + AND a.questionid = c.questionid{$rsql} + INNER JOIN {questionnaire_response_other} ro ON ro.choiceid = c.id + AND ro.responseid = a.responseid + AND ro.questionid = c.questionid + WHERE c.questionid = ? AND c.content = '!other' AND ro.response <> '' GROUP BY ro.response, c.id ORDER BY c.id"; - return $DB->get_records_sql($osql, array_merge($params, [$this->question->id])); + return $DB->get_records_sql($osql, array_merge($params, [$this->question->id()])); } /** @@ -331,18 +336,18 @@ public function get_feedback_scores(array $rids) { global $DB; $rsql = ''; - $params = [$this->question->id]; + $params = [$this->question->id()]; if (!empty($rids)) { [$rsql, $rparams] = $DB->get_in_or_equal($rids); $params = array_merge($params, $rparams); - $rsql = ' AND response_id ' . $rsql; + $rsql = ' AND responseid ' . $rsql; } $params[] = 'y'; - $sql = 'SELECT r.id, r.response_id as rid, r.question_id AS qid, r.choice_id AS cid, r.rankvalue ' . + $sql = 'SELECT r.id, r.responseid as rid, r.questionid AS qid, r.choiceid AS cid, r.rankvalue ' . 'FROM {' . $this->response_table() . '} r ' . - 'INNER JOIN {questionnaire_quest_choice} c ON r.choice_id = c.id ' . - 'WHERE r.question_id= ? ' . $rsql . ' ' . + 'INNER JOIN {questionnaire_quest_choice} c ON r.choiceid = c.id ' . + 'WHERE r.questionid= ? ' . $rsql . ' ' . 'ORDER BY rid,cid ASC'; $responses = $DB->get_recordset_sql($sql, $params); @@ -440,13 +445,13 @@ public static function response_select($rid) { $sql = 'SELECT a.id as aid, q.id AS qid, q.precise AS precise, c.id AS cid, q.content, c.content as ccontent, a.rankvalue as arank ' . 'FROM {' . static::response_table() . '} a, {questionnaire_question} q, {questionnaire_quest_choice} c ' . - 'WHERE a.response_id= ? AND a.question_id=q.id AND a.choice_id=c.id ' . - 'ORDER BY aid, a.question_id, c.id'; + 'WHERE a.responseid= ? AND a.questionid=q.id AND a.choiceid=c.id ' . + 'ORDER BY aid, a.questionid, c.id'; $records = $DB->get_records_sql($sql, [$rid]); foreach ($records as $row) { // Next two are 'qid' and 'cid', each with numeric and hash keys. $osgood = false; - if (\mod_questionnaire\question\rate::type_is_osgood_rate_scale($row->precise)) { + if (\mod_questionnaire\local\question\rate::type_is_osgood_rate_scale($row->precise)) { $osgood = true; } $qid = $row->qid . '_' . $row->cid; @@ -461,18 +466,18 @@ public static function response_select($rid) { if ($key == 'ccontent') { if ($osgood) { [$contentleft, $contentright] = array_merge(preg_split('/[|]/', $val), [' ']); - $contents = questionnaire_choice_values($contentleft); + $contents = question::parse_choice_content($contentleft); if ($contents->title) { $contentleft = $contents->title; } - $contents = questionnaire_choice_values($contentright); + $contents = question::parse_choice_content($contentright); if ($contents->title) { $contentright = $contents->title; } $val = strip_tags($contentleft . '|' . $contentright); $val = preg_replace("/[\r\n\t]/", ' ', $val); } else { - $contents = questionnaire_choice_values($val); + $contents = question::parse_choice_content($val); if ($contents->modname) { $val = $contents->modname; } else if ($contents->title) { @@ -505,16 +510,16 @@ public static function response_answers_by_question($rid) { $answers = []; $sql = 'SELECT r.id, - r.response_id AS responseid, - r.question_id AS questionid, - r.choice_id AS choiceid, + r.responseid AS responseid, + r.questionid AS questionid, + r.choiceid AS choiceid, r.rankvalue AS value, rt.response AS otheresponse FROM {' . static::response_table() . '} r - LEFT JOIN {questionnaire_response_other} rt ON rt.choice_id = r.choice_id - AND r.question_id = rt.question_id - AND r.response_id = rt.response_id - WHERE r.response_id = ?'; + LEFT JOIN {questionnaire_response_other} rt ON rt.choiceid = r.choiceid + AND r.questionid = rt.questionid + AND r.responseid = rt.responseid + WHERE r.responseid = ?'; $records = $DB->get_records_sql($sql, [$rid]); foreach ($records as $record) { $answers[$record->questionid][$record->choiceid] = answer\answer::create_from_data($record); @@ -542,16 +547,16 @@ private function mkresavg($sort, $stravgvalue = '') { $stravgrank = get_string('averagerank', 'questionnaire'); $osgood = false; - if ($this->question->precise == 3) { // Osgood's semantic differential. + if ($this->question->precise() == 3) { // Osgood's semantic differential. $osgood = true; $stravgrank = get_string('averageposition', 'questionnaire'); } $stravg = '
' . $stravgrank . $stravgvalue . '
'; - $isna = $this->question->precise == 1; + $isna = $this->question->precise() == 1; $isnahead = ''; $nbchoices = count($this->counts); - $isrestricted = ($this->question->length < $nbchoices) && $this->question->precise == 2; + $isrestricted = ($this->question->length() < $nbchoices) && $this->question->precise() == 2; if ($isna) { $isnahead = get_string('notapplicable', 'questionnaire'); @@ -629,7 +634,7 @@ private function mkresavg($sort, $stravgvalue = '') { $imageurl = $CFG->wwwroot . '/mod/questionnaire/images/hbar.gif'; $spacerimage = $CFG->wwwroot . '/mod/questionnaire/images/hbartransp.gif'; - $llength = $this->question->length; + $llength = $this->question->length(); if (!$llength) { $llength = 5; } @@ -644,7 +649,7 @@ private function mkresavg($sort, $stravgvalue = '') { $n[$nameddegrees] = $degree; $nameddegrees++; } - for ($j = 0; $j < $this->question->length; $j++) { + for ($j = 0; $j < $this->question->length(); $j++) { if (isset($n[$j])) { $str = $n[$j]; } else { @@ -709,7 +714,7 @@ private function mkresavg($sort, $stravgvalue = '') { if ($avg) { if (($j = $avg * $width) > 0) { - $marginposition = ($avg - 0.5 ) / ($this->question->length + $isrestricted); + $marginposition = ($avg - 0.5 ) / ($this->question->length() + $isrestricted); } if (!right_to_left()) { $margin = 'margin-left:' . $marginposition * 100 . '%'; @@ -727,16 +732,16 @@ private function mkresavg($sort, $stravgvalue = '') { // Ensure there are two bits of content. [$content, $contentright] = array_merge(preg_split('/[|]/', $content), [' ']); } else { - $contents = questionnaire_choice_values($content); + $contents = question::parse_choice_content($content); if ($contents->modname) { $content = $contents->text; } } if ( isset($contentobj->content) && - \mod_questionnaire\question\choice::content_other_choice_display($contentobj->content) + \mod_questionnaire\local\question\choice::content_other_choice_display($contentobj->content) ) { - $othertext = \mod_questionnaire\question\choice::content_other_choice_display($contentobj->content); + $othertext = \mod_questionnaire\local\question\choice::content_other_choice_display($contentobj->content); $content = $othertext . ' ' . clean_text($content); } if ($osgood) { @@ -855,28 +860,28 @@ private function mkrescount($rids, $rows, $sort) { $nbresponses = count($rids); // Prepare data to be displayed. - $isrestricted = ($this->question->length < count($this->question->choices)) && $this->question->precise == 2; + $isrestricted = ($this->question->length() < count($this->question->choices)) && $this->question->precise() == 2; $rsql = ''; if (!empty($rids)) { [$rsql, $params] = $DB->get_in_or_equal($rids); - $rsql = ' AND response_id ' . $rsql; + $rsql = ' AND responseid ' . $rsql; } - // This is question_id. - array_push($params, $this->question->id); + // This is questionid. + array_push($params, $this->question->id()); $sql = "SELECT r.id, CASE WHEN c.content = '!other' THEN o.response ELSE c.content END as content, r.rankvalue, c.id AS choiceid FROM {questionnaire_quest_choice} c - INNER JOIN {" . static::response_table() . "} r ON r.question_id = c.question_id - AND r.choice_id = c.id{$rsql} - LEFT JOIN {questionnaire_response_other} o ON o.choice_id = c.id - AND o.response_id = r.response_id - AND o.question_id = c.question_id - WHERE c.question_id = ? AND (c.content != '!other' OR (o.response IS NOT NULL AND o.response <> '')) + INNER JOIN {" . static::response_table() . "} r ON r.questionid = c.questionid + AND r.choiceid = c.id{$rsql} + LEFT JOIN {questionnaire_response_other} o ON o.choiceid = c.id + AND o.responseid = r.responseid + AND o.questionid = c.questionid + WHERE c.questionid = ? AND (c.content != '!other' OR (o.response IS NOT NULL AND o.response <> '')) ORDER BY choiceid, rankvalue ASC"; $choices = $DB->get_records_sql($sql, $params); @@ -901,7 +906,7 @@ private function mkrescount($rids, $rows, $sort) { break; } } - $nbranks = $this->question->length; + $nbranks = $this->question->length(); $ranks = []; $rankvalue = []; if (!empty($this->question->nameddegrees)) { @@ -932,12 +937,12 @@ private function mkrescount($rids, $rows, $sort) { // Psettings for display. $strtotal = '' . get_string('total', 'questionnaire') . ''; - $isna = $this->question->precise == 1; + $isna = $this->question->precise() == 1; $osgood = false; - if ($this->question->precise == 3) { // Osgood's semantic differential. + if ($this->question->precise() == 3) { // Osgood's semantic differential. $osgood = true; } - if ($this->question->precise == 1) { + if ($this->question->precise() == 1) { $na = get_string('notapplicable', 'questionnaire'); } else { $na = ''; @@ -950,7 +955,7 @@ private function mkrescount($rids, $rows, $sort) { $nameddegrees++; } foreach ($this->question->choices as $choice) { - $contents = questionnaire_choice_values($choice->content); + $contents = question::parse_choice_content($choice->content); if ($contents->modname) { $choice->content = $contents->text; } @@ -970,7 +975,7 @@ private function mkrescount($rids, $rows, $sort) { ]; // Display the column titles. - for ($j = 0; $j < $this->question->length; $j++) { + for ($j = 0; $j < $this->question->length(); $j++) { if (isset($n[$j])) { $str = $n[$j]; } else { @@ -1012,7 +1017,7 @@ private function mkrescount($rids, $rows, $sort) { 'text' => format_text($content, FORMAT_HTML, ['noclean' => true, 'filter' => false])]; } else { // Eliminate potentially short-named choices. - $contents = questionnaire_choice_values($content); + $contents = question::parse_choice_content($content); if ($contents->modname) { $content = $contents->text; } @@ -1025,7 +1030,7 @@ private function mkrescount($rids, $rows, $sort) { } // Display ranks/rates numbers. $maxrank = max($rank); - for ($i = 1; $i <= $this->question->length; $i++) { + for ($i = 1; $i <= $this->question->length(); $i++) { $percent = ''; if (isset($rank[$i])) { $str = $rank[$i]; diff --git a/classes/local/response/response.php b/classes/local/response/response.php new file mode 100644 index 000000000..bf21835f6 --- /dev/null +++ b/classes/local/response/response.php @@ -0,0 +1,295 @@ +. + +namespace mod_questionnaire\local\response; + +use mod_questionnaire\local\db\response_record; + +/** + * The response domain object — a single user's submission to a questionnaire instance. + * + * Persistent-backed domain object representing a questionnaire_response row. + * Provides typed accessors (id(), userid(), questionnaireid(), is_complete(), + * submitted_at(), grade()) and operations (create(), touch(), commit(), + * add_questions_answers()) for the full response lifecycle. + * + * @package mod_questionnaire + * @copyright 2025 onward Mike Churchward (mike.churchward@poetopensource.org) + * @author Mike Churchward + * @license http://www.gnu.org/copyleft/gpl.html GNU Public License + */ +class response { + /** @var response_record The response header record. */ + protected response_record $record; + + /** + * Array of answers by question id, each an array of answer objects. + * Populated by add_questions_answers() or response_from_webform(). + * + * @var array + */ + public array $answers = []; + + /** @var int Current section number; set by the mobile app flow to track navigation state. */ + public int $sec = 0; + + /** @var int Response ID alias; set by callers that pass this object to response_insert(). */ + public int $rid = 0; + + /** + * Construct from a response_record. + * + * @param response_record|null $record Null creates an empty record. + * @param bool $addanswers When true, load all question answers from the DB. + */ + public function __construct(?response_record $record = null, bool $addanswers = false) { + $this->record = $record ?? new response_record(); + if ($addanswers) { + $this->add_questions_answers(); + } + } + + // Factories. + + /** + * Build a response from an array or stdClass of raw DB field values. + * + * Used when constructing a response object from a record already fetched + * from the DB (e.g. by questionnaire_responses::add_user_responses). + * + * @param array|\stdClass $responsedata + * @return self + */ + public static function create_from_data($responsedata): self { + if (!is_array($responsedata)) { + $responsedata = (array)$responsedata; + } + $data = new \stdClass(); + foreach (['id', 'questionnaireid', 'userid', 'submitted', 'complete', 'grade'] as $field) { + if (isset($responsedata[$field])) { + $data->$field = $responsedata[$field]; + } + } + return new self(new response_record(0, $data), true); + } + + /** + * Build a response from web form submission data, including all question answers. + * + * @param \stdClass $responsedata All of the responsedata as an object. + * @param array $questions Array of question objects. + * @return self + */ + public static function response_from_webform(\stdClass $responsedata, array $questions): self { + global $USER; + + $questionnaireid = isset($responsedata->questionnaire_id) ? $responsedata->questionnaire_id : + (isset($responsedata->a) ? $responsedata->a : 0); + + $data = new \stdClass(); + $data->id = (int)$responsedata->rid; + $data->questionnaireid = (int)$questionnaireid; + $data->userid = (int)$USER->id; + $response = new self(new response_record(0, $data), false); + + foreach ($questions as $question) { + if ($question->supports_responses()) { + $response->answers[$question->id()] = $question->responsetype::answers_from_webform( + $responsedata, + $question + ); + } + } + return $response; + } + + /** + * Build a response from mobile app submission data, including all question answers. + * + * @param int $questionnaireid + * @param int $responseid + * @param \stdClass $responsedata All of the responsedata as an object. + * @param array $questions Array of question objects. + * @return self + */ + public static function response_from_appdata( + int $questionnaireid, + int $responseid, + \stdClass $responsedata, + array $questions + ): self { + global $USER; + + $data = new \stdClass(); + $data->id = $responseid; + $data->questionnaireid = $questionnaireid; + $data->userid = (int)$USER->id; + $response = new self(new response_record(0, $data), false); + + // Process app data by question and choice into a webform-compatible structure. + $processedresponses = new \stdClass(); + $processedresponses->rid = $responseid; + foreach ($responsedata as $answerid => $value) { + $parts = explode('_', $answerid); + if ($parts[0] == 'response') { + $qid = 'q' . $parts[2]; + if (!isset($processedresponses->{$qid})) { + $processedresponses->{$qid} = []; + } + $cid = isset($parts[3]) ? $parts[3] : 0; + $processedresponses->{$qid}[$cid] = $value; + } + } + + foreach ($questions as $question) { + if ($question->supports_responses() && isset($processedresponses->{'q' . $question->id()})) { + $response->answers[$question->id()] = $question->responsetype::answers_from_appdata( + $processedresponses, + $question + ); + } + } + return $response; + } + + /** + * Return the most recent incomplete response for the given user and questionnaire, or null if none. + * + * Delegates to response_record::get_latest_incomplete(). + * + * @param int $questionnaireid + * @param int $userid + * @return self|null + */ + public static function latest_incomplete(int $questionnaireid, int $userid): ?self { + $record = response_record::get_latest_incomplete($questionnaireid, $userid); + return $record === null ? null : new self($record); + } + + /** + * Create and persist a new incomplete response header for the given questionnaire and user. + * + * @param int $questionnaireid + * @param int $userid + * @return self + */ + public static function create(int $questionnaireid, int $userid): self { + $record = new response_record(); + $record->set('questionnaireid', $questionnaireid); + $record->set('userid', $userid); + $record->set('submitted', time()); + $record->set('complete', 'n'); + $record->create(); + return new self($record); + } + + // Typed accessors. + + /** + * Get the response id. + * + * @return int + */ + public function id(): int { + return (int) $this->record->get('id'); + } + + /** + * Get the user id. + * + * @return int + */ + public function userid(): int { + return (int) $this->record->get('userid'); + } + + /** + * Get the questionnaire id this response belongs to. + * + * @return int + */ + public function questionnaireid(): int { + return (int) $this->record->get('questionnaireid'); + } + + /** + * True if this response has been marked as complete. + * + * @return bool + */ + public function is_complete(): bool { + return $this->record->get('complete') === 'y'; + } + + /** + * Get the submitted timestamp. + * + * @return int + */ + public function submitted_at(): int { + return (int) $this->record->get('submitted'); + } + + /** + * Get the grade value. + * + * @return int + */ + public function grade(): int { + return (int) $this->record->get('grade'); + } + + // Operations. + + /** + * Update the submitted timestamp on this response header. + * + * @return void + */ + public function touch(): void { + $this->record->set('submitted', time()); + $this->record->update(); + } + + /** + * Mark this response as complete and set its grade. + * + * @param int $grade + * @return void + */ + public function commit(int $grade = 0): void { + $this->record->set('complete', 'y'); + $this->record->set('submitted', time()); + $this->record->set('grade', $grade); + $this->record->update(); + } + + /** + * Load and populate $this->answers with all question answers for this response. + * + * @return void + */ + public function add_questions_answers(): void { + $this->answers = []; + $this->answers += \mod_questionnaire\local\response\multiple::response_answers_by_question($this->id()); + $this->answers += \mod_questionnaire\local\response\single::response_answers_by_question($this->id()); + $this->answers += \mod_questionnaire\local\response\rank::response_answers_by_question($this->id()); + $this->answers += \mod_questionnaire\local\response\boolean::response_answers_by_question($this->id()); + $this->answers += \mod_questionnaire\local\response\date::response_answers_by_question($this->id()); + $this->answers += \mod_questionnaire\local\response\text::response_answers_by_question($this->id()); + $this->answers += \mod_questionnaire\local\response\file::response_answers_by_question($this->id()); + } +} diff --git a/classes/responsetype/responsetype.php b/classes/local/response/responsetype.php similarity index 86% rename from classes/responsetype/responsetype.php rename to classes/local/response/responsetype.php index 4edb69c09..fcc8a7c07 100644 --- a/classes/responsetype/responsetype.php +++ b/classes/local/response/responsetype.php @@ -14,7 +14,7 @@ // You should have received a copy of the GNU General Public License // along with Moodle. If not, see . -namespace mod_questionnaire\responsetype; +namespace mod_questionnaire\local\response; use html_writer; use html_table; @@ -30,22 +30,49 @@ */ abstract class responsetype { // Class properties. - /** @var \mod_questionnaire\question\question $question The question for this response. */ + /** @var \mod_questionnaire\local\question\question $question The question for this response. */ public $question; /** @var int $responseid The id of the response this is for. */ public $responseid; - /** @var array $choices An array of \mod_questionnaire\responsetype\choice objects. */ + /** @var array $choices An array of \mod_questionnaire\local\response\choice objects. */ public $choices; + /** @var bool Whether the current user may view individual responses. */ + protected bool $canviewsingleresponse = false; + + /** @var string Respondent type ('anonymous' or other) for the owning questionnaire. */ + protected string $displayrespondenttype = ''; + + /** @var int Survey ID for the owning questionnaire (used to build report URLs). */ + protected int $displaysurveysid = 0; + + /** + * Provide the values needed by text and file response result renderers so they + * do not need to read global $questionnaire. + * + * @param bool $viewsingleresponse Whether the current user may view a single response. + * @param string $respondenttype 'anonymous' or '' / named. + * @param int $surveyid The survey id of the owning questionnaire. + */ + public function set_display_context(bool $viewsingleresponse, string $respondenttype, int $surveyid): void { + $this->canviewsingleresponse = $viewsingleresponse; + $this->displayrespondenttype = $respondenttype; + $this->displaysurveysid = $surveyid; + } + /** * responsetype constructor. - * @param \mod_questionnaire\question\question $question + * @param \mod_questionnaire\local\question\question $question * @param int|null $responseid * @param array|null $choices */ - public function __construct(\mod_questionnaire\question\question $question, ?int $responseid = null, ?array $choices = []) { + public function __construct( + \mod_questionnaire\local\question\question $question, + ?int $responseid = null, + ?array $choices = [] + ) { $this->question = $question; $this->responseid = $responseid; $this->choices = $choices; @@ -75,8 +102,8 @@ public static function all_response_tables() { * 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. + * @param \mod_questionnaire\local\question\question $question + * @return array \mod_questionnaire\local\response\answer\answer An array of answer objects. */ abstract public static function answers_from_webform($responsedata, $question); @@ -260,14 +287,14 @@ public static function response_answers_by_question($rid) { * Provide an array of answer objects from mobile 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. + * @param \mod_questionnaire\local\question\question $question + * @return array \mod_questionnaire\local\response\answer\answer An array of answer objects. */ public static function answers_from_appdata($responsedata, $question) { // In most cases this can be a direct call to answers_from_webform with the one modification below. Override when this will // not work. - if (isset($responsedata->{'q' . $question->id}) && !empty($responsedata->{'q' . $question->id})) { - $responsedata->{'q' . $question->id} = $responsedata->{'q' . $question->id}[0]; + if (isset($responsedata->{'q' . $question->id()}) && !empty($responsedata->{'q' . $question->id()})) { + $responsedata->{'q' . $question->id()} = $responsedata->{'q' . $question->id()}[0]; } return static::answers_from_webform($responsedata, $question); } @@ -395,11 +422,12 @@ protected function bulk_sql() { $alias . '.id', ] ) . " AS id, - qr.submitted, qr.complete, qr.grade, qr.userid, $userfields, qr.id AS rid, $alias.question_id, + qr.submitted, qr.complete, qr.grade, qr.userid, $userfields, qr.id AS rid, + $alias.questionid AS questionid, $extraselect FROM {questionnaire_response} qr JOIN {" . $config->table . "} $alias - ON $alias.response_id = qr.id + ON $alias.responseid = qr.id "; } } diff --git a/classes/responsetype/single.php b/classes/local/response/single.php similarity index 73% rename from classes/responsetype/single.php rename to classes/local/response/single.php index a00c46b2a..3036ba716 100644 --- a/classes/responsetype/single.php +++ b/classes/local/response/single.php @@ -14,7 +14,9 @@ // You should have received a copy of the GNU General Public License // along with Moodle. If not, see . -namespace mod_questionnaire\responsetype; +namespace mod_questionnaire\local\response; + +use mod_questionnaire\local\question\question; /** * Class for single response types. @@ -39,24 +41,24 @@ public static function response_table() { * 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. + * @param \mod_questionnaire\local\question\question $question + * @return array \mod_questionnaire\local\response\answer\answer An array of answer objects. * @throws \coding_exception */ public static function answers_from_webform($responsedata, $question) { $answers = []; - if (isset($responsedata->{'q' . $question->id}) && isset($question->choices[$responsedata->{'q' . $question->id}])) { + if (isset($responsedata->{'q' . $question->id()}) && isset($question->choices[$responsedata->{'q' . $question->id()}])) { $record = new \stdClass(); $record->responseid = $responsedata->rid; - $record->questionid = $question->id; - $record->choiceid = $responsedata->{'q' . $question->id}; + $record->questionid = $question->id(); + $record->choiceid = $responsedata->{'q' . $question->id()}; // If this choice is an "other" choice, look for the added input. - if ($question->choices[$responsedata->{'q' . $question->id}]->is_other_choice()) { - $cname = 'q' . $question->id . - \mod_questionnaire\question\choice::id_other_choice_name($responsedata->{'q' . $question->id}); + if ($question->choices[$responsedata->{'q' . $question->id()}]->is_other_choice()) { + $cname = 'q' . $question->id() . + \mod_questionnaire\local\question\choice::id_other_choice_name($responsedata->{'q' . $question->id()}); $record->value = isset($responsedata->{$cname}) ? $responsedata->{$cname} : ''; } - $answers[$responsedata->{'q' . $question->id}] = answer\answer::create_from_data($record); + $answers[$responsedata->{'q' . $question->id()}] = answer\answer::create_from_data($record); } return $answers; } @@ -65,20 +67,20 @@ public static function answers_from_webform($responsedata, $question) { * Provide an array of answer objects from mobile 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. + * @param \mod_questionnaire\local\question\question $question + * @return array \mod_questionnaire\local\response\answer\answer An array of answer objects. */ public static function answers_from_appdata($responsedata, $question) { $answers = []; - $qname = 'q' . $question->id; + $qname = 'q' . $question->id(); if (isset($responsedata->{$qname}[0]) && !empty($responsedata->{$qname}[0])) { $record = new \stdClass(); $record->responseid = $responsedata->rid; - $record->questionid = $question->id; + $record->questionid = $question->id(); $record->choiceid = $responsedata->{$qname}[0]; // If this choice is an "other" choice, look for the added input. if ($question->choices[$record->choiceid]->is_other_choice()) { - $cname = \mod_questionnaire\question\choice::id_other_choice_name($record->choiceid); + $cname = \mod_questionnaire\local\question\choice::id_other_choice_name($record->choiceid); $record->value = isset($responsedata->{$qname}[$cname]) ? $responsedata->{$qname}[$cname] : ''; } else { @@ -89,6 +91,16 @@ public static function answers_from_appdata($responsedata, $question) { return $answers; } + /** + * Create and return the primary response record for this type. + * Overridden by multiple to return a response_multiple_record instead. + * + * @return \core\persistent + */ + protected function make_primary_record(): \core\persistent { + return new \mod_questionnaire\local\db\response_single_record(); + } + /** * Insert a provided response to the question. * @@ -96,36 +108,38 @@ public static function answers_from_appdata($responsedata, $question) { * @return int|bool - on error the subtype should call set_error and return false. */ 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]); + if (!$responsedata instanceof \mod_questionnaire\local\response\response) { + $response = \mod_questionnaire\local\response\response::response_from_webform( + $responsedata, + [$this->question] + ); } else { $response = $responsedata; } $resid = false; - if (!empty($response) && isset($response->answers[$this->question->id])) { - foreach ($response->answers[$this->question->id] as $answer) { + if (!empty($response) && isset($response->answers[$this->question->id()])) { + foreach ($response->answers[$this->question->id()] as $answer) { if (isset($this->question->choices[$answer->choiceid])) { if ($this->question->choices[$answer->choiceid]->is_other_choice()) { // If no input specified, ignore this choice. if (empty($answer->value) || preg_match("/^[\s]*$/", $answer->value)) { continue; } - $record = new \stdClass(); - $record->response_id = $response->id; - $record->question_id = $this->question->id; - $record->choice_id = $answer->choiceid; - $record->response = clean_text($answer->value); - $DB->insert_record('questionnaire_response_other', $record); + $otherrec = new \mod_questionnaire\local\db\response_other_record(); + $otherrec->set('responseid', $response->id()); + $otherrec->set('questionid', $this->question->id()); + $otherrec->set('choiceid', $answer->choiceid); + $otherrec->set('response', clean_text($answer->value)); + $otherrec->create(); } // Record the choice selection. - $record = new \stdClass(); - $record->response_id = $response->id; - $record->question_id = $this->question->id; - $record->choice_id = $answer->choiceid; - $resid = $DB->insert_record(static::response_table(), $record); + $rec = $this->make_primary_record(); + $rec->set('responseid', $response->id()); + $rec->set('questionid', $this->question->id()); + $rec->set('choiceid', $answer->choiceid); + $rec->create(); + $resid = $rec->get('id'); } } } @@ -143,19 +157,19 @@ public function get_results($rids = false, $anonymous = false) { global $DB; $rsql = ''; - $params = [$this->question->id]; + $params = [$this->question->id()]; if (!empty($rids)) { [$rsql, $rparams] = $DB->get_in_or_equal($rids); $params = array_merge($params, $rparams); - $rsql = ' AND response_id ' . $rsql; + $rsql = ' AND responseid ' . $rsql; } // Added qc.id to preserve original choices ordering. $sql = 'SELECT rt.id, qc.id as cid, qc.content ' . 'FROM {questionnaire_quest_choice} qc, ' . '{' . static::response_table() . '} rt ' . - 'WHERE qc.question_id= ? AND qc.content NOT LIKE \'!other%\' AND ' . - 'rt.question_id=qc.question_id AND rt.choice_id=qc.id' . $rsql . ' ' . + 'WHERE qc.questionid= ? AND qc.content NOT LIKE \'!other%\' AND ' . + 'rt.questionid=qc.questionid AND rt.choiceid=qc.id' . $rsql . ' ' . 'ORDER BY qc.id'; $rows = $DB->get_records_sql($sql, $params); @@ -164,7 +178,7 @@ public function get_results($rids = false, $anonymous = false) { $sql = 'SELECT rt.id, rt.response, qc.content ' . 'FROM {questionnaire_response_other} rt, ' . '{questionnaire_quest_choice} qc ' . - 'WHERE rt.question_id= ? AND rt.choice_id=qc.id' . $rsql . ' ' . + 'WHERE rt.questionid= ? AND rt.choiceid=qc.id' . $rsql . ' ' . 'ORDER BY qc.id'; if ($recs = $DB->get_records_sql($sql, $params)) { @@ -189,19 +203,19 @@ public function get_feedback_scores(array $rids) { global $DB; $rsql = ''; - $params = [$this->question->id]; + $params = [$this->question->id()]; if (!empty($rids)) { [$rsql, $rparams] = $DB->get_in_or_equal($rids); $params = array_merge($params, $rparams); - $rsql = ' AND response_id ' . $rsql; + $rsql = ' AND responseid ' . $rsql; } $params[] = 'y'; - $sql = 'SELECT response_id as rid, c.value AS score ' . + $sql = 'SELECT responseid as rid, c.value AS score ' . 'FROM {' . $this->response_table() . '} r ' . - 'INNER JOIN {questionnaire_quest_choice} c ON r.choice_id = c.id ' . - 'WHERE r.question_id= ? ' . $rsql . ' ' . - 'ORDER BY response_id ASC'; + 'INNER JOIN {questionnaire_quest_choice} c ON r.choiceid = c.id ' . + 'WHERE r.questionid= ? ' . $rsql . ' ' . + 'ORDER BY responseid ASC'; return $DB->get_records_sql($sql, $params); } @@ -240,14 +254,14 @@ public function display_results($rids = false, $sort = '', $anonymous = false) { $rsql = ''; if (!empty($rids)) { [$rsql, $params] = $DB->get_in_or_equal($rids); - $rsql = ' AND response_id ' . $rsql; + $rsql = ' AND responseid ' . $rsql; } - $responsecountsql = 'SELECT COUNT(DISTINCT r.response_id) ' . + $responsecountsql = 'SELECT COUNT(DISTINCT r.responseid) ' . 'FROM {' . $this->response_table() . '} r, ' . '{questionnaire_response} qr ' . - 'WHERE question_id = ' . $this->question->id . $rsql . - ' AND r.response_id = qr.id '; + 'WHERE questionid = ' . $this->question->id() . $rsql . + ' AND r.responseid = qr.id '; $numrespondents = $DB->count_records_sql($responsecountsql, $params); if ($rows) { @@ -256,12 +270,12 @@ public function display_results($rids = false, $sort = '', $anonymous = false) { if (strpos($idx, 'other') === 0) { $answer = $row->response; $ccontent = $row->content; - $content = \mod_questionnaire\question\choice::content_other_choice_display($ccontent); + $content = \mod_questionnaire\local\question\choice::content_other_choice_display($ccontent); $content .= ' ' . clean_text($answer); $textidx = $content; $counts[$textidx] = !empty($counts[$textidx]) ? ($counts[$textidx] + 1) : 1; } else { - $contents = questionnaire_choice_values($row->content); + $contents = question::parse_choice_content($row->content); $textidx = $contents->text . $contents->image; $counts[$textidx] = !empty($counts[$textidx]) ? ($counts[$textidx] + 1) : 1; } @@ -286,18 +300,18 @@ public static function response_select($rid) { $values = []; $sql = 'SELECT a.id, q.id as qid, q.content, c.content as ccontent, c.id as cid, o.response ' . 'FROM {' . static::response_table() . '} a ' . - 'INNER JOIN {questionnaire_question} q ON a.question_id = q.id ' . - 'INNER JOIN {questionnaire_quest_choice} c ON a.choice_id = c.id ' . - 'LEFT JOIN {questionnaire_response_other} o ON a.response_id = o.response_id AND c.id = o.choice_id ' . - 'WHERE a.response_id = ? '; + 'INNER JOIN {questionnaire_question} q ON a.questionid = q.id ' . + 'INNER JOIN {questionnaire_quest_choice} c ON a.choiceid = c.id ' . + 'LEFT JOIN {questionnaire_response_other} o ON a.responseid = o.responseid AND c.id = o.choiceid ' . + 'WHERE a.responseid = ? '; $records = $DB->get_records_sql($sql, [$rid]); foreach ($records as $row) { $newrow['content'] = $row->content; $newrow['ccontent'] = $row->ccontent; $newrow['responses'] = []; $newrow['responses'][$row->cid] = $row->cid; - if (\mod_questionnaire\question\choice::content_is_other_choice($row->ccontent)) { - $newrow['responses'][\mod_questionnaire\question\choice::id_other_choice_name($row->cid)] = $row->response; + if (\mod_questionnaire\local\question\choice::content_is_other_choice($row->ccontent)) { + $newrow['responses'][\mod_questionnaire\local\question\choice::id_other_choice_name($row->cid)] = $row->response; } $values[$row->qid] = $newrow; } @@ -317,12 +331,12 @@ public static function response_answers_by_question($rid) { global $DB; $answers = []; - $sql = 'SELECT r.id as id, r.response_id as responseid, r.question_id as questionid, r.choice_id as choiceid, ' . + $sql = 'SELECT r.id as id, r.responseid as responseid, r.questionid as questionid, r.choiceid as choiceid, ' . 'o.response as value ' . 'FROM {' . static::response_table() . '} r ' . - 'LEFT JOIN {questionnaire_response_other} o ON r.response_id = o.response_id AND r.question_id = o.question_id AND ' . - 'r.choice_id = o.choice_id ' . - 'WHERE r.response_id = ? '; + 'LEFT JOIN {questionnaire_response_other} o ON r.responseid = o.responseid AND r.questionid = o.questionid AND ' . + 'r.choiceid = o.choiceid ' . + 'WHERE r.responseid = ? '; $records = $DB->get_records_sql($sql, [$rid]); foreach ($records as $record) { $answers[$record->questionid][$record->choiceid] = answer\answer::create_from_data($record); @@ -368,7 +382,7 @@ public function get_bulk_sql($questionnaireids, $responseid = false, $userid = f $sql .= " AND qr.questionnaireid $qsql $showcompleteonly - LEFT JOIN {questionnaire_response_other} qro ON qro.response_id = qr.id AND qro.choice_id = qrs.choice_id + LEFT JOIN {questionnaire_response_other} qro ON qro.responseid = qr.id AND qro.choiceid = qrs.choiceid LEFT JOIN {user} u ON u.id = qr.userid $groupsql "; @@ -395,14 +409,15 @@ protected function bulk_sql() { $userfields = $this->user_fields_sql(); $alias = 'qrs'; - $extraselect = 'qrs.choice_id, ' . $DB->sql_order_by_text('qro.response', 1000) . ' AS response, 0 AS rankvalue'; + $extraselect = 'qrs.choiceid, ' . $DB->sql_order_by_text('qro.response', 1000) . ' AS response, 0 AS rankvalue'; return " SELECT " . $DB->sql_concat_join("'_'", ['qr.id', "'" . $this->question->helpname() . "'", $alias . '.id']) . " AS id, - qr.submitted, qr.complete, qr.grade, qr.userid, $userfields, qr.id AS rid, $alias.question_id, + qr.submitted, qr.complete, qr.grade, qr.userid, $userfields, qr.id AS rid, + $alias.questionid AS questionid, $extraselect FROM {questionnaire_response} qr - JOIN {" . static::response_table() . "} $alias ON $alias.response_id = qr.id + JOIN {" . static::response_table() . "} $alias ON $alias.responseid = qr.id "; } } diff --git a/classes/responsetype/slider.php b/classes/local/response/slider.php similarity index 84% rename from classes/responsetype/slider.php rename to classes/local/response/slider.php index 6538c733e..043791011 100644 --- a/classes/responsetype/slider.php +++ b/classes/local/response/slider.php @@ -14,7 +14,7 @@ // You should have received a copy of the GNU General Public License // along with Moodle. If not, see . -namespace mod_questionnaire\responsetype; +namespace mod_questionnaire\local\response; /** * Class for slider text response types. @@ -37,11 +37,11 @@ public static function response_answers_by_question($rid) { global $DB; $answers = []; - $sql = 'SELECT qs.id, qs.response_id as responseid, qs.question_id as questionid, + $sql = 'SELECT qs.id, qs.responseid as responseid, qs.questionid as questionid, 0 as choiceid, qs.response as value, qq.extradata ' . 'FROM {' . static::response_table() . '} qs ' . - 'INNER JOIN {questionnaire_question} qq ON qq.id = qs.question_id ' . - 'WHERE response_id = ? '; + 'INNER JOIN {questionnaire_question} qq ON qq.id = qs.questionid ' . + 'WHERE responseid = ? '; $records = $DB->get_records_sql($sql, [$rid]); foreach ($records as $record) { $answers[$record->questionid][] = answer\answer::create_from_data($record); @@ -60,16 +60,16 @@ public static function response_answers_by_question($rid) { public function get_feedback_scores(array $rids) { global $DB; $rsql = ''; - $params = [$this->question->id]; + $params = [$this->question->id()]; if (!empty($rids)) { [$rsql, $rparams] = $DB->get_in_or_equal($rids); $params = array_merge($params, $rparams); - $rsql = ' AND response_id ' . $rsql; + $rsql = ' AND responseid ' . $rsql; } - $sql = 'SELECT response_id as rid, response AS score ' . + $sql = 'SELECT responseid as rid, response AS score ' . 'FROM {' . $this->response_table() . '} r ' . - 'WHERE r.question_id= ? ' . $rsql . ' ' . - 'ORDER BY response_id ASC'; + 'WHERE r.questionid= ? ' . $rsql . ' ' . + 'ORDER BY responseid ASC'; return $DB->get_records_sql($sql, $params); } } diff --git a/classes/responsetype/text.php b/classes/local/response/text.php similarity index 85% rename from classes/responsetype/text.php rename to classes/local/response/text.php index c7907f08b..48f850c04 100644 --- a/classes/responsetype/text.php +++ b/classes/local/response/text.php @@ -14,7 +14,7 @@ // You should have received a copy of the GNU General Public License // along with Moodle. If not, see . -namespace mod_questionnaire\responsetype; +namespace mod_questionnaire\local\response; use mod_questionnaire\db\bulk_sql_config; @@ -40,16 +40,16 @@ public static function response_table() { * 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. + * @param \mod_questionnaire\local\question\question $question + * @return array \mod_questionnaire\local\response\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}; + 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; + $record->questionid = $question->id(); $record->value = $val; $answers[] = answer\answer::create_from_data($record); } @@ -63,23 +63,24 @@ public static function answers_from_webform($responsedata, $question) { * @return int|bool - on error the subtype should call set_error and return false. */ 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]); + if (!$responsedata instanceof \mod_questionnaire\local\response\response) { + $response = \mod_questionnaire\local\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->response = clean_text($response->answers[$this->question->id][0]->value); - return $DB->insert_record(static::response_table(), $record); - } else { - return false; + if (!empty($response) && isset($response->answers[$this->question->id()][0])) { + $rec = new \mod_questionnaire\local\db\response_text_record(); + $rec->set('responseid', $response->id()); + $rec->set('questionid', $this->question->id()); + $rec->set('response', clean_text($response->answers[$this->question->id()][0]->value)); + $rec->create(); + return $rec->get('id'); } + return false; } /** @@ -95,7 +96,7 @@ public function get_results($rids = false, $anonymous = false) { $rsql = ''; if (!empty($rids)) { [$rsql, $params] = $DB->get_in_or_equal($rids); - $rsql = ' AND response_id ' . $rsql; + $rsql = ' AND responseid ' . $rsql; } if ($anonymous) { @@ -103,8 +104,8 @@ public function get_results($rids = false, $anonymous = false) { '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 ' . + 'WHERE questionid=' . $this->question->id() . $rsql . + ' AND t.responseid = r.id ' . 'ORDER BY r.submitted DESC'; } else { $sql = 'SELECT t.id, t.response, r.submitted AS submitted, r.userid, u.username AS username, ' . @@ -113,8 +114,8 @@ public function get_results($rids = false, $anonymous = false) { 'FROM {' . static::response_table() . '} t, ' . '{questionnaire_response} r, ' . '{user} u ' . - 'WHERE question_id=' . $this->question->id . $rsql . - ' AND t.response_id = r.id' . + 'WHERE questionid=' . $this->question->id() . $rsql . + ' AND t.responseid = r.id' . ' AND u.id = r.userid ' . 'ORDER BY r.submitted DESC'; } @@ -176,15 +177,15 @@ public function get_results_tags($weights, $participants, $respondents, $showtot // 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'; + global $CFG, $SESSION, $DB; + $viewsingleresponse = $this->canviewsingleresponse; + $nonanonymous = $this->displayrespondenttype !== '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 . + $url = $CFG->wwwroot . '/mod/questionnaire/report.php?action=vresp&sid=' . $this->displaysurveysid . '¤tgroupid=' . $currentgroupid; } $users = []; @@ -213,7 +214,7 @@ public function get_results_tags($weights, $participants, $respondents, $showtot if (count($weights) > 1) { $pagetags->sortresponse = true; } - $pagetags->tableid = $this->question->id; + $pagetags->tableid = $this->question->id(); if ($showtotals == 1) { $pagetags->total = new \stdClass(); $pagetags->total->total = "($respondents)"; @@ -249,7 +250,7 @@ public function get_results_tags($weights, $participants, $respondents, $showtot $response = new \stdClass(); $response->respondent = $straverage; $avg = $sum / $nbresponses; - $response->text = sprintf('%.' . $this->question->precise . 'f', $avg); + $response->text = sprintf('%.' . $this->question->precise() . 'f', $avg); $response->evencolor = $evencolor; $pagetags->responses[] = (object)['response' => $response]; $evencolor = !$evencolor; @@ -277,7 +278,7 @@ public static function response_select($rid) { $values = []; $sql = 'SELECT q.id, q.content, a.response as aresponse ' . 'FROM {' . static::response_table() . '} a, {questionnaire_question} q ' . - 'WHERE a.response_id=? AND a.question_id=q.id '; + 'WHERE a.responseid=? AND a.questionid=q.id '; $records = $DB->get_records_sql($sql, [$rid]); foreach ($records as $qid => $row) { unset($row->id); @@ -308,9 +309,9 @@ 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, response as value ' . + $sql = 'SELECT id, responseid as responseid, questionid as questionid, 0 as choiceid, response as value ' . 'FROM {' . static::response_table() . '} ' . - 'WHERE response_id = ? '; + 'WHERE responseid = ? '; $records = $DB->get_records_sql($sql, [$rid]); foreach ($records as $record) { $answers[$record->questionid][] = answer\answer::create_from_data($record); diff --git a/classes/manager.php b/classes/manager.php deleted file mode 100644 index c5468be2f..000000000 --- a/classes/manager.php +++ /dev/null @@ -1,179 +0,0 @@ -. - -namespace mod_questionnaire; - -use cm_info; -use context_module; -use stdClass; - -/** - * Class manager for questionnaire activity - * - * @package mod_questionnaire - * @copyright 2025 Luca Bösch - * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later - */ -class manager { - /** Module name. */ - public const MODULE = 'questionnaire'; - - /** @var context_module the current context. */ - private $context; - - /** @var stdClass $course record. */ - private $course; - - /** @var \moodle_database the database instance. */ - private \moodle_database $db; - - /** - * Class constructor. - * - * @param cm_info $cm course module info object - * @param stdClass $instance activity instance object. - */ - public function __construct( - /** @var cm_info $cm the given course module info */ - private cm_info $cm, - /** @var stdClass $instance activity instance object */ - private stdClass $instance - ) { - $this->context = context_module::instance($cm->id); - $this->db = \core\di::get(\moodle_database::class); - $this->course = $cm->get_course(); - } - - /** - * Create a manager instance from an instance record. - * - * @param stdClass $instance an activity record - * @return manager - */ - public static function create_from_instance(stdClass $instance): self { - $cm = get_coursemodule_from_instance(self::MODULE, $instance->id); - // Ensure that $this->cm is a cm_info object. - $cm = cm_info::create($cm); - return new self($cm, $instance); - } - - /** - * Create a manager instance from a course_modules record. - * - * @param stdClass|cm_info $cm an activity record - * @return manager - */ - public static function create_from_coursemodule(stdClass|cm_info $cm): self { - // Ensure that $this->cm is a cm_info object. - $cm = cm_info::create($cm); - $db = \core\di::get(\moodle_database::class); - $instance = $db->get_record(self::MODULE, ['id' => $cm->instance], '*', MUST_EXIST); - return new self($cm, $instance); - } - - /** - * Return the current context. - * - * @return context_module - */ - public function get_context(): context_module { - return $this->context; - } - - /** - * Return the current instance. - * - * @return stdClass the instance record - */ - public function get_instance(): stdClass { - return $this->instance; - } - - /** - * Return the current cm_info. - * - * @return cm_info the course module - */ - public function get_coursemodule(): cm_info { - return $this->cm; - } - - /** - * Return the current count of users who have answered this questionnaire module, that the current user can see. - * - * @param int[] $groupids the group identifiers to filter by, empty array means no filtering - * @param int|null $optionid the option ID to filter by, or null to count all answers - * @return int the number of answers that the user can see - */ - public function count_all_users_answered( - array $groupids = [], - ?int $optionid = null, - ): int { - if (!has_capability('mod/questionnaire:viewsingleresponse', $this->context)) { - return 0; - } - - $tableprefix = empty($groupids) ? '' : 'qr.'; - $select = $tableprefix . 'questionnaireid = :questionnaireid'; - $params = [ - 'questionnaireid' => $this->instance->id, - ]; - if ($optionid) { - $select .= ' AND ' . $tableprefix . 'optionid = :optionid '; - $params['optionid'] = $optionid; - } - - if (empty($groupids)) { - // No groups filtering, count all users answered. - return $this->db->count_records_select('questionnaire_response', $select, $params, 'COUNT(DISTINCT userid)'); - } - - // Groups filtering is applied. - [$gsql, $gparams] = $this->db->get_in_or_equal($groupids, SQL_PARAMS_NAMED); - $query = "SELECT COUNT(DISTINCT qr.userid) - FROM {questionnaire_response} qr, {groups_members} gm - WHERE $select - AND (gm.groupid $gsql OR gm.groupid = 0) - AND qr.userid = gm.userid"; - return $this->db->count_records_sql($query, $params + $gparams); - } - - /** - * Check if the current user has answered the questionnaire. - * - * Note: this will count all answers, regardless of grouping. - * - * @return bool true if the user has answered, false otherwise - */ - public function has_answered(): bool { - global $USER; - $conditions = ['questionnaireid' => $this->instance->id, 'userid' => $USER->id]; - return $this->db->record_exists('questionnaire_response', $conditions); - } - - /** - * Get the options for this questionnaire activity. - * - * @return array of questionnaire options - */ - public function get_options(): array { - return $this->db->get_records( - 'questionnaire_options', - ['questionnaireid' => $this->instance->id], - 'id ASC', - ); - } -} diff --git a/classes/output/mobile.php b/classes/output/mobile.php index 8f9155cc0..8fa694a13 100644 --- a/classes/output/mobile.php +++ b/classes/output/mobile.php @@ -16,7 +16,8 @@ namespace mod_questionnaire\output; -use mod_questionnaire\responsetype\response\response; +use mod_questionnaire\local\response\response; +use mod_questionnaire\questionnaire as questionnaire_class; /** * Mobile output class for mod_questionnaire. @@ -34,8 +35,7 @@ class mobile { * @return array HTML, javascript and other data */ public static function mobile_view_activity($args) { - global $OUTPUT, $USER, $CFG, $DB; - require_once($CFG->dirroot . '/mod/questionnaire/questionnaire.class.php'); + global $OUTPUT, $USER; $args = (object) $args; @@ -48,23 +48,23 @@ public static function mobile_view_activity($args) { $submit = isset($args->submit) ? $args->submit : false; $completed = isset($args->completed) ? $args->completed : false; - [$cm, $course, $questionnaire] = questionnaire_get_standard_page_items($cmid); - $questionnaire = new \questionnaire($course, $cm, 0, $questionnaire); + $questionnaire = questionnaire_class::from_cmid($cmid); + $cm = $questionnaire->coursemodule(); $data = []; $data['cmid'] = $cmid; $data['userid'] = $userid; - $data['intro'] = $questionnaire->intro; - $data['autonumquestions'] = $questionnaire->autonum; - $data['id'] = $questionnaire->id; + $data['intro'] = $questionnaire->intro(); + $data['autonumquestions'] = $questionnaire->questions_autonumbered(); + $data['id'] = $questionnaire->id(); $data['rid'] = $rid; - $data['surveyid'] = $questionnaire->survey->id; + $data['surveyid'] = $questionnaire->surveyid(); $data['pagenum'] = $pagenum; $data['prevpage'] = 0; $data['nextpage'] = 0; // Capabilities check. - $context = \context_module::instance($cmid); + $context = $questionnaire->context(); self::require_capability($cm, $context, 'mod/questionnaire:view'); // Any notifications will be displayed on top of main page, and prevent questionnaire from being completed. This also checks @@ -101,8 +101,10 @@ public static function mobile_view_activity($args) { $questionnaire->add_response($rid); $data['rid'] = $rid; } - $response = (isset($questionnaire->responses) && !empty($questionnaire->responses)) ? - end($questionnaire->responses) : \mod_questionnaire\responsetype\response\response::create_from_data([]); + $loadedresponses = $questionnaire->responses()->get_loaded_responses(); + $response = !empty($loadedresponses) ? + end($loadedresponses) : + \mod_questionnaire\local\response\response::create_from_data([]); $response->sec = $pagenum; if (isset($result['warnings'])) { if ($action == 'submit') { @@ -112,7 +114,7 @@ public static function mobile_view_activity($args) { } else if ($action == 'nextpage') { $pageresult = $result['nextpagenum']; if ($pageresult === false) { - $pagenum = count($questionnaire->questionsbysec); + $pagenum = count($questionnaire->questions_by_section_all()); } else if (is_string($pageresult)) { $data['notifications'] .= !empty($data['notifications']) ? "\n
$pageresult" : $pageresult; } else { @@ -134,9 +136,10 @@ public static function mobile_view_activity($args) { $pagequestiondata = self::add_pagequestion_data($questionnaire, $pagenum, $response); $data['pagequestions'] = $pagequestiondata['pagequestions']; $responses = $pagequestiondata['responses']; - $numpages = count($questionnaire->questionsbysec); + $questionsbysec = $questionnaire->questions_by_section_all(); + $numpages = count($questionsbysec); // Set some variables we are going to be using. - if (!empty($questionnaire->questionsbysec) && ($numpages > 1)) { + if (!empty($questionsbysec) && ($numpages > 1)) { if ($pagenum > 1) { $data['prevpage'] = true; } @@ -153,14 +156,20 @@ public static function mobile_view_activity($args) { case 'review': // If reviewing a submission. - if ($questionnaire->capabilities->readownresponses && isset($args->submissionid) && !empty($args->submissionid)) { + if ( + $questionnaire->capabilities()->can_read_own_responses() && + isset($args->submissionid) && !empty($args->submissionid) + ) { $questionnaire->add_response($args->submissionid); - $response = $questionnaire->responses[$args->submissionid]; + $response = $questionnaire->responses()->get_response($args->submissionid); $qnum = 1; $pagequestions = []; - foreach ($questionnaire->questions as $question) { + foreach ($questionnaire->questions() as $question) { if ($question->supports_mobile()) { - $pagequestions[] = $question->mobile_question_display($qnum, $questionnaire->autonum); + $pagequestions[] = $question->mobile_question_display( + $qnum, + $questionnaire->questions_autonumbered() + ); $responses = array_merge($responses, $question->get_mobile_response_data($response)); if ($question->is_numbered()) { $qnum++; @@ -207,17 +216,17 @@ protected static function require_capability(\stdClass $cm, \context $context, s /** * Add the submissions. - * @param \questionnaire $questionnaire + * @param questionnaire_class $questionnaire * @param array $data * @param int $userid */ - protected static function add_index_data($questionnaire, &$data, $userid) { + protected static function add_index_data(questionnaire_class $questionnaire, array &$data, int $userid): void { // List any existing submissions, if user is allowed to review them. - if ($questionnaire->capabilities->readownresponses) { + if ($questionnaire->capabilities()->can_read_own_responses()) { $questionnaire->add_user_responses(); $submissions = []; - foreach ($questionnaire->responses as $response) { - $submissions[] = ['submissiondate' => userdate($response->submitted), 'submissionid' => $response->id]; + foreach ($questionnaire->responses()->get_loaded_responses() as $response) { + $submissions[] = ['submissiondate' => userdate($response->submitted_at()), 'submissionid' => $response->id()]; } if (!empty($submissions)) { $data['submissions'] = $submissions; @@ -232,23 +241,29 @@ protected static function add_index_data($questionnaire, &$data, $userid) { } /** - * Ass the questions for the page. - * @param \questionnaire $questionnaire + * Add the questions for the page. + * @param questionnaire_class $questionnaire * @param int $pagenum - * @param response $response + * @param response|null $response * @return array */ - protected static function add_pagequestion_data($questionnaire, $pagenum, $response = null) { + protected static function add_pagequestion_data( + questionnaire_class $questionnaire, + int $pagenum, + ?response $response = null + ): array { $qnum = 1; $pagequestions = []; $responses = []; + $questionsbysec = $questionnaire->questions_by_section_all(); + $autonumbered = $questionnaire->questions_autonumbered(); - // Find out what question number we are on $i New fix for question numbering. + // Find out what question number we are on — new fix for question numbering. $i = 0; if ($pagenum > 1) { for ($j = 2; $j <= $pagenum; $j++) { - foreach ($questionnaire->questionsbysec[$j - 1] as $questionid) { - if ($questionnaire->questions[$questionid]->type_id < QUESPAGEBREAK) { + foreach ($questionsbysec[$j - 1] as $question) { + if ($question->typeid() < QUESPAGEBREAK) { $i++; } } @@ -256,15 +271,14 @@ protected static function add_pagequestion_data($questionnaire, $pagenum, $respo } $qnum = $i + 1; - foreach ($questionnaire->questionsbysec[$pagenum] as $questionid) { - $question = $questionnaire->questions[$questionid]; + foreach ($questionsbysec[$pagenum] as $question) { if ($question->supports_mobile()) { - $pagequestions[] = $question->mobile_question_display($qnum, $questionnaire->autonum); + $pagequestions[] = $question->mobile_question_display($qnum, $autonumbered); $mobileotherdata = $question->mobile_otherdata(); if (!empty($mobileotherdata)) { $responses = array_merge($responses, $mobileotherdata); } - if (($response !== null) && isset($response->answers[$questionid])) { + if (($response !== null) && isset($response->answers[$question->id()])) { $responses = array_merge($responses, $question->get_mobile_response_data($response)); } if ($question->is_numbered()) { diff --git a/classes/output/pdf_factory.php b/classes/output/pdf_factory.php new file mode 100644 index 000000000..c095b2909 --- /dev/null +++ b/classes/output/pdf_factory.php @@ -0,0 +1,56 @@ +. + +namespace mod_questionnaire\output; + +/** + * Factory for the report-page TCPDF object used by report.php in PDF mode. + * + * @package mod_questionnaire + * @copyright 2026 Mike Churchward (mike.churchward@poetopensource.org) + * @author Mike Churchward + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class pdf_factory { + /** + * Return a configured \pdf instance with one landscape page already added. + * + * @return \pdf + */ + public static function create(): \pdf { + global $CFG; + require_once($CFG->libdir . '/pdflib.php'); + + $pdf = new \pdf(); + $pdf->SetCreator(PDF_CREATOR); + $pdf->SetAuthor('Moodle Questionnaire'); + $pdf->SetTitle('All responses'); + $pdf->setPrintHeader(false); + $pdf->SetDefaultMonospacedFont(PDF_FONT_MONOSPACED); + + $pdf->SetMargins(PDF_MARGIN_LEFT, PDF_MARGIN_TOP, PDF_MARGIN_RIGHT); + $pdf->SetHeaderMargin(PDF_MARGIN_HEADER); + $pdf->SetFooterMargin(PDF_MARGIN_FOOTER); + + $pdf->SetAutoPageBreak(true, PDF_MARGIN_BOTTOM); + + $pdf->setImageScale(PDF_IMAGE_SCALE_RATIO); + $pdf->SetFillColor(238, 238, 238); + $pdf->AddPage('L'); + + return $pdf; + } +} diff --git a/classes/output/renderer.php b/classes/output/renderer.php index dd198b55d..334e0158c 100755 --- a/classes/output/renderer.php +++ b/classes/output/renderer.php @@ -16,7 +16,7 @@ namespace mod_questionnaire\output; -use mod_questionnaire\question\question; +use mod_questionnaire\local\question\question; /** * Contains class mod_questionnaire\output\renderer @@ -151,7 +151,12 @@ public function complete_formstart($action, $hiddeninputs = []) { foreach ($hiddeninputs as $name => $value) { $output .= \html_writer::empty_tag('input', ['type' => 'hidden', 'name' => $name, 'value' => $value]) . "\n"; } - $this->page->requires->js_init_call('M.mod_questionnaire.init_attempt_form', null, false, questionnaire_get_js_module()); + $this->page->requires->js_init_call( + 'M.mod_questionnaire.init_attempt_form', + null, + false, + \mod_questionnaire\form_options::js_module() + ); return $output; } @@ -221,16 +226,33 @@ public function render_progress_bar($section, $questionsbysec) { /** * Render a question for a survey. - * @param \mod_questionnaire\question\question $question The question object. - * @param \mod_questionnaire\responsetype\response\response $response Any current response data. + * @param \mod_questionnaire\local\question\question $question The question object. + * @param \mod_questionnaire\local\response\response $response Any current response data. * @param int $qnum The question number. * @param boolean $blankquestionnaire Used for printing a blank one. * @param array $dependants Array of all questions/choices depending on $question. + * @param \questionnaire|null $questionnaire The parent questionnaire object. + * @param bool $individualresponse True when rendering a single individual response (mybyresponse / individualresp). * @return string The output for the page. */ - public function question_output($question, $response, $qnum, $blankquestionnaire, $dependants = []) { - - $pagetags = $question->question_output($response, $blankquestionnaire, $dependants, $qnum); + public function question_output( + $question, + $response, + $qnum, + $blankquestionnaire, + $dependants = [], + $questionnaire = null, + bool $individualresponse = false + ) { + + $pagetags = $question->question_output( + $response, + $blankquestionnaire, + $dependants, + $qnum, + $questionnaire, + $individualresponse + ); // If the question has a template, then render it from the 'qformelement' context. If no template, then 'qformelement' // already contains HTML. @@ -244,22 +266,31 @@ public function question_output($question, $response, $qnum, $blankquestionnaire $pagetags->notifications = $this->notification($notification, \core\output\notification::NOTIFY_ERROR); } } - $pagetags->questionname = $question->name; + $pagetags->questionname = $question->name(); return $this->render_from_template('mod_questionnaire/question_container', $pagetags); } /** * Render a question response. - * @param \mod_questionnaire\question\question $question The question object. - * @param \mod_questionnaire\responsetype\response\response $response The response object. + * @param \mod_questionnaire\local\question\question $question The question object. + * @param \mod_questionnaire\local\response\response $response The response object. * @param int $qnum The question number. * @param bool $pdf + * @param \questionnaire|null $questionnaire The parent questionnaire object. + * @param bool $individualresponse True when rendering a single individual response (mybyresponse / individualresp). * @return string The output for the page. * @throws \moodle_exception */ - public function response_output($question, $response, $qnum = null, $pdf = false) { - $pagetags = $question->response_output($response, $qnum); + public function response_output( + $question, + $response, + $qnum = null, + $pdf = false, + $questionnaire = null, + bool $individualresponse = false + ) { + $pagetags = $question->response_output($response, $qnum, $questionnaire, $individualresponse); // If the response has a template, then render it from the 'qformelement' context. If no template, then 'qformelement' // already contains HTML. @@ -287,26 +318,29 @@ public function response_output($question, $response, $qnum = null, $pdf = false * Render all responses for a question. * @param array|string $responses * @param array $questions + * @param \questionnaire|null $questionnaire The parent questionnaire object. + * @param bool $individualresponse True when rendering a single individual response (mybyresponse / individualresp). * @return string The output for the page. */ - public function all_response_output($responses, $questions = null) { + public function all_response_output($responses, $questions = null, $questionnaire = null, bool $individualresponse = false) { $output = ''; if (is_string($responses)) { $output .= $responses; } else { $qnum = 1; foreach ($questions as $question) { - if (empty($pagetags = $question->questionstart_survey_display($qnum))) { + $pagetags = $question->questionstart_survey_display($qnum, null, $questionnaire, $individualresponse); + if (empty($pagetags)) { continue; } foreach ($responses as $response) { - $resptags = $question->response_output($response); + $resptags = $question->response_output($response, '', $questionnaire, $individualresponse); // If the response has a template, then render it from the 'qformelement' context. // If no template, then 'qformelement' already contains HTML. if (($template = $question->response_template())) { $resptags->qformelement = $this->render_from_template($template, $resptags->qformelement); } - $resptags->respdate = userdate($response->submitted); + $resptags->respdate = userdate($response->submitted_at()); $pagetags->responses[] = $resptags; } $qnum++; @@ -476,7 +510,7 @@ public function dependency_warnings($children, $langstring, $strnum) { * @return string */ public function get_dependency_html($qid, $dependencies) { - $html = ''; + $context = ['dependencies' => []]; foreach ($dependencies as $dependency) { switch ($dependency->dependlogic) { case 0: @@ -488,23 +522,24 @@ public function get_dependency_html($qid, $dependencies) { default: $logic = ''; } - - // TODO - Move the HTML generation to the renderer. - if ($dependency->dependandor == "and") { - $html .= '
' . '' . - get_string('dependquestion', 'questionnaire') . ' : ' . - get_string('position', 'questionnaire') . ' ' . - $dependency->parentposition . ' (' . $dependency->parent . ') ' . $logic . '
'; + if ($dependency->dependandor == 'and') { + $divid = 'qdepend_' . $qid . '_' . $dependency->dependquestionid . '_' . $dependency->dependchoiceid; + $divclass = 'qdepend'; } else { - $html .= '
' . '' . - get_string('dependquestion', 'questionnaire') . ' : ' . - get_string('position', 'questionnaire') . ' ' . - $dependency->parentposition . ' (' . $dependency->parent . ') ' . $logic . '
'; + $divid = 'qdepend_or_' . $qid . '_' . $dependency->dependquestionid . '_' . $dependency->dependchoiceid; + $divclass = 'qdepend-or'; } + $context['dependencies'][] = [ + 'divid' => $divid, + 'divclass' => $divclass, + 'dependquestion' => get_string('dependquestion', 'questionnaire'), + 'position' => get_string('position', 'questionnaire'), + 'parentposition' => $dependency->parentposition, + 'parent' => $dependency->parent, + 'logicstr' => $logic, + ]; } - return $html; + return $this->output->render_from_template('mod_questionnaire/dependencylist', $context); } /** diff --git a/classes/output/report_view_renderer.php b/classes/output/report_view_renderer.php new file mode 100644 index 000000000..c147d571d --- /dev/null +++ b/classes/output/report_view_renderer.php @@ -0,0 +1,294 @@ +. + +namespace mod_questionnaire\output; + +use mod_questionnaire\questionnaire; + +/** + * Builds the printable and preview survey views. + * + * Companion to {@see survey_view_renderer} for the read-only paths: the + * print and preview pages (build_print_view) and individual response views + * (render_response). Re-uses survey_view_renderer for the shared + * title/respondent header. + * + * @package mod_questionnaire + * @copyright 2026 onward Mike Churchward (mike.churchward@poetopensource.org) + * @author Mike Churchward + * @license http://www.gnu.org/copyleft/gpl.html GNU Public License + */ +class report_view_renderer { + /** + * Constructor. + * + * @param \plugin_renderer_base $renderer The plugin's Moodle renderer. + * @param object $page A templatable page object (previewpage, reportpage, etc.). + */ + public function __construct( + /** @var \plugin_renderer_base The plugin's Moodle renderer. */ + protected readonly \plugin_renderer_base $renderer, + /** @var object A templatable page object (previewpage, reportpage, etc.). */ + protected readonly object $page, + ) { + } + + /** + * Build the print / preview page for the survey. + * + * Delegates to render_response when a specific response id is supplied; + * otherwise renders every question in section order with optional + * preview-mode dependency hints and submit form. + * + * @param questionnaire $q + * @param int $courseid Unused legacy parameter retained for caller compatibility. + * @param string $message Error message displayed at the top of the form. + * @param string $referer 'preview', 'print', or empty. + * @param int $rid Response id to render, or 0 for blank/all. + * @param bool $blankquestionnaire True when rendering a blank questionnaire. + * @return false|void + */ + public function build_print_view( + questionnaire $q, + $courseid, + $message = '', + $referer = '', + $rid = 0, + $blankquestionnaire = false + ) { + global $CFG; + + if (!empty($rid)) { + $this->render_response($q, $rid, $referer); + return; + } + + $section = 1; + $questionsbysec = $q->questions_by_section_all(); + $numsections = count($questionsbysec); + + if ($section > $numsections) { + return false; + } + + $hasrequired = $q->survey()->has_required(); + + $i = 1; + for ($j = 2; $j <= $section; $j++) { + $i += count($questionsbysec[$j - 1]); + } + + $action = $CFG->wwwroot . '/mod/questionnaire/preview.php?' . + ($q->coursemodule() ? 'id=' . $q->coursemodule()->id : 'sid=' . $q->surveyid()); + $this->page->add_to_page('formstart', $this->renderer->complete_formstart($action)); + + $formdata = new \stdClass(); + $errors = 1; + if (data_submitted()) { + $formdata = data_submitted(); + $formdata->rid = $formdata->rid ?? 0; + $q->responses()->add_response_from_formdata($formdata); + $pageerror = ''; + $s = 1; + $errors = 0; + foreach ($questionsbysec as $sec) { + $errormessage = $q->responses()->response_check_format($s, $formdata); + if ($errormessage) { + if ($numsections > 1) { + $pageerror = get_string('page', 'questionnaire') . ' ' . $s . ' : '; + } + $this->page->add_to_page( + 'notifications', + $this->renderer->notification( + $pageerror . $errormessage, + \core\output\notification::NOTIFY_ERROR + ) + ); + $errors++; + } + $s++; + } + } + + $this->build_header($q, $message, 1, 1, $hasrequired, ''); + + if (($referer == 'preview') && $q->has_dependencies()) { + $allqdependants = $q->navigator()->get_dependants_and_choices(); + } else { + $allqdependants = []; + } + if ($errors == 0) { + $this->page->add_to_page( + 'message', + $this->renderer->notification( + get_string('submitpreviewcorrect', 'questionnaire'), + \core\output\notification::NOTIFY_SUCCESS + ) + ); + } + + $page = 1; + foreach ($questionsbysec as $sec) { + $output = ''; + if ($numsections > 1) { + $output .= $this->renderer->print_preview_pagenumber( + get_string('page', 'questionnaire') . ' ' . $page + ); + $page++; + } + foreach ($sec as $question) { + if (!$question->is_numbered()) { + $i--; + } + $dependants = $allqdependants[$question->id()] ?? []; + $question->set_isprint($referer === 'print'); + $output .= $this->renderer->question_output( + $question, + ($q->responses()->get_response(0) ?? new \mod_questionnaire\local\response\response()), + $i++, + null, + $dependants, + $q + ); + $this->page->add_to_page('questions', $output); + $output = ''; + } + } + + if ($referer == 'preview' && !$blankquestionnaire) { + $url = $CFG->wwwroot . '/mod/questionnaire/preview.php?' . + ($q->coursemodule() ? 'id=' . $q->coursemodule()->id : 'sid=' . $q->surveyid()); + $this->page->add_to_page( + 'formend', + $this->renderer->print_preview_formend( + $url, + get_string('submitpreview', 'questionnaire'), + get_string('reset') + ) + ); + } + } + + /** + * Render a single response for viewing (report, print, or preview context). + * + * @param questionnaire $q + * @param int $rid + * @param string $referer 'print' suppresses feedback messages. + * @param mixed $resps + * @param bool $compare + * @param bool $isgroupmember + * @param bool $allresponses + * @param int $currentgroupid + * @param string $outputtarget 'html' or 'pdf'. + * @return void + */ + public function render_response( + questionnaire $q, + $rid, + $referer = '', + $resps = '', + $compare = false, + $isgroupmember = false, + $allresponses = false, + $currentgroupid = 0, + $outputtarget = 'html' + ): void { + $this->build_header($q, '', 1, 1, 0, $rid, false, $outputtarget); + + $i = 0; + $q->add_response($rid); + if ($referer != 'print') { + $feedbackmessages = $q->reporter($this->renderer, $this->page)->response_analysis( + $rid, + $resps, + $compare, + $isgroupmember, + $allresponses, + $currentgroupid + ); + if ($feedbackmessages) { + $msgout = ''; + foreach ($feedbackmessages as $msg) { + $msgout .= $msg; + } + $this->page->add_to_page('feedbackmessages', $msgout); + } + + $feedbacknotes = $q->survey()->feedbacknotes(); + if ($feedbacknotes) { + $text = file_rewrite_pluginfile_urls( + $feedbacknotes, + 'pluginfile.php', + $q->context()->id, + 'mod_questionnaire', + 'feedbacknotes', + $q->surveyid() + ); + $this->page->add_to_page('feedbacknotes', $this->renderer->box(format_text($text, FORMAT_HTML))); + } + } + $pdf = ($outputtarget == 'pdf'); + foreach ($q->questions() as $question) { + if (!$question->dependency_fulfilled($rid, $q->questions())) { + continue; + } + if ($question->typeid() < QUESPAGEBREAK) { + $i++; + } + if ($question->typeid() != QUESPAGEBREAK) { + $this->page->add_to_page( + 'responses', + $this->renderer->response_output( + $question, + $q->responses()->get_response($rid), + $i, + $pdf, + $q, + true + ) + ); + } + } + } + + /** + * Delegate the title / respondent / print-blank header to survey_view_renderer so + * the two render paths share a single implementation. + * + * @param questionnaire $q + * @param string $message + * @param int $section + * @param int $numsections + * @param bool $hasrequired + * @param int|string $rid + * @param bool $blankquestionnaire + * @param string $outputtarget + */ + private function build_header( + questionnaire $q, + $message, + $section, + $numsections, + $hasrequired, + $rid = '', + $blankquestionnaire = false, + $outputtarget = 'html' + ): void { + (new survey_view_renderer($this->renderer, $this->page)) + ->print_survey_start($q, $message, $section, $numsections, $hasrequired, $rid, $blankquestionnaire, $outputtarget); + } +} diff --git a/classes/output/survey_view_renderer.php b/classes/output/survey_view_renderer.php new file mode 100644 index 000000000..2e2a3cdde --- /dev/null +++ b/classes/output/survey_view_renderer.php @@ -0,0 +1,717 @@ +. + +namespace mod_questionnaire\output; + +use mod_questionnaire\questionnaire; +use mod_questionnaire\submission_notifier; + +/** + * Builds the student-facing survey-completion page. + * + * Takes the Moodle plugin renderer and a templatable page object, and fills the + * page in place from data on the supplied questionnaire. Replaces the + * page-filling responsibilities that used to live on the questionnaire class + * itself (view(), print_survey(), and their private helpers). + * + * @package mod_questionnaire + * @copyright 2026 onward Mike Churchward (mike.churchward@poetopensource.org) + * @author Mike Churchward + * @license http://www.gnu.org/copyleft/gpl.html GNU Public License + */ +class survey_view_renderer { + /** + * Constructor. + * + * @param \plugin_renderer_base $renderer The plugin's Moodle renderer. + * @param object $page A templatable page object (viewpage, completepage, etc.) with an add_to_page() method. + */ + public function __construct( + /** @var \plugin_renderer_base The plugin's Moodle renderer. */ + protected readonly \plugin_renderer_base $renderer, + /** @var object A templatable page object (viewpage, completepage, etc.). */ + protected readonly object $page, + ) { + } + + /** + * Build the main view page for a user taking the survey. + * + * Mirrors the legacy questionnaire::view(): writes an access-denied notification + * when access is blocked, otherwise renders the survey form and processes a + * "Submit Survey" POST when one is present. + * + * @param questionnaire $q + * @param int $userid The user viewing the page. + * @return void + */ + public function build_view(questionnaire $q, int $userid): void { + $message = $q->user_access_messages($userid, true); + if ($message !== null) { + $this->page->add_to_page('notifications', $message); + return; + } + $msg = $this->build_survey_form($q, $userid, $userid); + + $viewform = data_submitted(); + if ( + $viewform && confirm_sesskey() && + isset($viewform->submit) && isset($viewform->submittype) && + ($viewform->submittype == "Submit Survey") && empty($msg) + ) { + if (!empty($viewform->rid)) { + $viewform->rid = (int)$viewform->rid; + } + if (!empty($viewform->sec)) { + $viewform->sec = (int)$viewform->sec; + } + $rid = $q->submission()->existing_response_action($viewform, $userid); + $q->responses()->commit_submission_response($rid, $userid); + (new submission_notifier($q))->notify($rid); + $this->goto_thankyou($q); + } + } + + /** + * Render the survey form for completion. + * + * Processes navigation form actions (next/prev/resume/submit) and fills the page + * with the current section's questions and control buttons. + * + * @param questionnaire $q + * @param int $quser + * @param int|null $userid + * @return string|null Error message string, or null on success. + */ + public function build_survey_form(questionnaire $q, int $quser, ?int $userid = null): ?string { + global $CFG; + + if (!($formdata = data_submitted()) || !confirm_sesskey()) { + $formdata = new \stdClass(); + } + + $formdata->rid = $q->get_latest_responseid($quser); + if (($formdata->rid != 0) && (empty($formdata->sec) || intval($formdata->sec) < 1)) { + $formdata->sec = $q->responses()->response_select_max_sec($formdata->rid); + } + if (empty($formdata->sec)) { + $formdata->sec = 1; + } else { + $formdata->sec = (intval($formdata->sec) > 0) ? intval($formdata->sec) : 1; + } + + $questionsbysec = $q->questions_by_section_all(); + $numsections = count($questionsbysec); + $msg = ''; + $action = $CFG->wwwroot . '/mod/questionnaire/complete.php?id=' . $q->coursemodule()->id; + + if (!empty($formdata->submit)) { + $submitresult = $this->handle_submit_action($q, $formdata, $userid); + if ($submitresult === null) { + return null; + } + $msg = $submitresult; + } + + if (!empty($formdata->resume) && ($q->resume())) { + $this->handle_resume_action($q, $formdata, $quser); + return null; + } + + if (!empty($formdata->next)) { + $msg = $this->handle_next_action($q, $formdata, $userid, $numsections); + } + + if (!empty($formdata->prev)) { + $msg = $this->handle_prev_action($q, $formdata, $userid); + } + + if (!empty($formdata->rid)) { + $q->add_response($formdata->rid); + } + + $formdatareferer = !empty($formdata->referer) ? htmlspecialchars($formdata->referer) : ''; + $formdatarid = isset($formdata->rid) ? $formdata->rid : '0'; + $this->page->add_to_page( + 'formstart', + $this->renderer->complete_formstart( + $action, + [ + 'referer' => $formdatareferer, + 'a' => $q->id(), + 'sid' => $q->surveyid(), + 'rid' => $formdatarid, + 'sec' => $formdata->sec, + 'end' => !empty($formdata->end) ? 1 : 0, + 'sesskey' => sesskey(), + ] + ) + ); + if ($q->questions() && $numsections) { + $this->survey_render($q, $formdata, $formdata->sec, $msg); + $controlbuttons = []; + if ($formdata->sec > 1) { + $controlbuttons['prev'] = [ + 'type' => 'submit', + 'class' => 'btn btn-secondary control-button-prev', + 'value' => '<< ' . get_string('previouspage', 'questionnaire'), + ]; + } + if ($q->resume()) { + $controlbuttons['resume'] = [ + 'type' => 'submit', + 'class' => 'btn btn-secondary control-button-save', + 'value' => get_string('save_and_exit', 'questionnaire'), + ]; + } + if ($formdata->sec == $numsections) { + $controlbuttons['submittype'] = ['type' => 'hidden', 'value' => 'Submit Survey']; + $controlbuttons['submit'] = [ + 'type' => 'submit', + 'class' => 'btn btn-primary control-button-submit', + 'value' => get_string('submitsurvey', 'questionnaire'), + ]; + } else { + $controlbuttons['next'] = [ + 'type' => 'submit', + 'class' => 'btn btn-secondary control-button-next', + 'value' => get_string('nextpage', 'questionnaire') . ' >>', + ]; + } + $this->page->add_to_page('controlbuttons', $this->renderer->complete_controlbuttons($controlbuttons)); + } else { + $this->page->add_to_page( + 'controlbuttons', + $this->renderer->complete_controlbuttons(get_string('noneinuse', 'questionnaire')) + ); + } + $this->page->add_to_page('formend', $this->renderer->complete_formend()); + + return $msg ?: null; + } + + /** + * Process the "Submit Survey" button press. + * + * Returns null when the caller should short-circuit (already past the end, + * or no validation errors so the response is now final). Returns a non-empty + * error string when validation failed; in that case formdata->rid is also + * updated to the in-progress response id so the form re-renders with errors. + * + * @param questionnaire $q + * @param \stdClass $formdata + * @param int|null $userid Owner of the response (anonymous = null). + * @return string|null null = short-circuit, string = error message to render. + */ + protected function handle_submit_action(questionnaire $q, \stdClass $formdata, ?int $userid): ?string { + if (!empty($formdata->end)) { + return null; + } + $msg = $q->responses()->response_check_format($formdata->sec, $formdata); + if (empty($msg)) { + return null; + } + $formdata->rid = $q->submission()->existing_response_action($formdata, $userid); + return $msg; + } + + /** + * Process the "Save and exit" button press: persist the in-progress response and show the saved page. + * + * Caller must return null after this; the saved page replaces the form. + * + * @param questionnaire $q + * @param \stdClass $formdata + * @param int $quser User id the response belongs to. + * @return void + */ + protected function handle_resume_action(questionnaire $q, \stdClass $formdata, int $quser): void { + $q->responses()->response_delete($formdata->rid, $formdata->sec); + $formdata->rid = $q->responses()->response_insert($formdata, $quser, true); + $this->goto_saved($q); + } + + /** + * Process the "Next page" button press. + * + * Returns an empty string on success (formdata->sec is advanced, possibly to numsections+1 if + * this was the final page). On validation failure returns the error message and re-stages the + * current section by clearing formdata->next and refreshing formdata->rid. + * + * @param questionnaire $q + * @param \stdClass $formdata + * @param int|null $userid + * @param int $numsections Total section count, used to compute the post-last-page sec. + * @return string Error message ('' on success). + */ + protected function handle_next_action( + questionnaire $q, + \stdClass $formdata, + ?int $userid, + int $numsections + ): string { + $msg = $q->responses()->response_check_format($formdata->sec, $formdata); + if ($msg) { + $formdata->next = ''; + $formdata->rid = $q->submission()->existing_response_action($formdata, $userid); + return $msg; + } + $nextsec = $q->submission()->next_page_action($formdata, $userid); + if ($nextsec === false) { + $formdata->end = 1; + $formdata->sec = $numsections + 1; + } else { + $formdata->sec = $nextsec; + } + return ''; + } + + /** + * Process the "Previous page" button press. + * + * If the user just returned from the (virtual) past-end summary page, walk back one section + * before validating. Returns an empty string on success (formdata->sec is decremented, possibly + * to 0 if this was the first page). On validation failure returns the error message and + * re-stages the current section by clearing formdata->prev and refreshing formdata->rid. + * + * @param questionnaire $q + * @param \stdClass $formdata + * @param int|null $userid + * @return string Error message ('' on success). + */ + protected function handle_prev_action(questionnaire $q, \stdClass $formdata, ?int $userid): string { + if (!empty($formdata->end)) { + $formdata->end = 0; + $formdata->sec--; + } + $msg = $q->responses()->response_check_format($formdata->sec, $formdata, false, true); + if ($msg) { + $formdata->prev = ''; + $formdata->rid = $q->submission()->existing_response_action($formdata, $userid); + return $msg; + } + $prevsec = $q->submission()->previous_page_action($formdata, $userid); + if ($prevsec === false) { + $formdata->sec = 0; + } else { + $formdata->sec = $prevsec; + } + return ''; + } + + /** + * Render the title, intro, group/respondent info, and print-blank link at the top of a survey page. + * + * @param questionnaire $q + * @param string $message Error message to display above the form, or empty. + * @param int $section Current section number (1-based). + * @param int $numsections Total number of sections in the survey. + * @param bool $hasrequired Whether any question in this section is required. + * @param int|string $rid Response id when viewing a specific response, or '' / 0 in completion mode. + * @param bool $blankquestionnaire True when rendering a blank questionnaire (print/preview). + * @param string $outputtarget 'html' or 'pdf'. + * @return void + */ + public function print_survey_start( + questionnaire $q, + $message, + $section, + $numsections, + $hasrequired, + $rid = '', + $blankquestionnaire = false, + $outputtarget = 'html' + ): void { + global $CFG; + require_once($CFG->libdir . '/filelib.php'); + + $userid = 0; + $resp = null; + $groupname = ''; + $currentgroupid = 0; + $timesubmitted = ''; + if ($rid) { + $courseid = $q->courseid(); + if ($resp = \mod_questionnaire\local\db\response_record::get_or_null((int)$rid)) { + if ($q->respondenttype() == 'fullname') { + $userid = (int)$resp->get('userid'); + if (groups_get_activity_groupmode($q->coursemodule(), $q->course())) { + if ($groups = groups_get_all_groups($courseid, $userid)) { + if (count($groups) == 1) { + $group = current($groups); + $currentgroupid = $group->id; + $groupname = ' (' . get_string('group') . ': ' . $group->name . ')'; + } else { + $groupname = ' (' . get_string('groups') . ': '; + foreach ($groups as $group) { + $groupname .= $group->name . ', '; + } + $groupname = substr($groupname, 0, strlen($groupname) - 2) . ')'; + } + } else { + $groupname = ' (' . get_string('groupnonmembers') . ')'; + } + } + + $params = [ + 'objectid' => $q->surveyid(), + 'context' => $q->context(), + 'courseid' => $q->courseid(), + 'relateduserid' => $userid, + 'other' => ['action' => 'vresp', 'currentgroupid' => $currentgroupid, 'rid' => $rid], + ]; + $event = \mod_questionnaire\event\response_viewed::create($params); + $event->trigger(); + } + } + } + $ruser = ''; + if ($resp && !$blankquestionnaire) { + if ($userid) { + if ($user = \core_user::get_user($userid)) { + $ruser = fullname($user); + } + } + if ($q->respondenttype() == 'anonymous') { + $ruser = '- ' . get_string('anonymous', 'questionnaire') . ' -'; + } else { + $submitted = (int)$resp->get('submitted'); + if ($submitted) { + $timesubmitted = ' ' . get_string('submitted', 'questionnaire') . + ' ' . userdate($submitted); + } + } + } + if ($ruser) { + $respinfo = ''; + if ($outputtarget == 'html') { + $linkname = get_string('print', 'mod_questionnaire'); + $link = new \moodle_url( + '/mod/questionnaire/report.php', + [ + 'action' => 'vresp', + 'instance' => $q->id(), + 'target' => 'print', + 'individualresponse' => 1, + 'rid' => $rid, + ] + ); + $htmlicon = new \pix_icon('t/print', $linkname); + $options = [ + 'menubar' => true, + 'location' => false, + 'scrollbars' => true, + 'resizable' => true, + 'height' => 600, + 'width' => 800, + 'title' => $linkname, + ]; + $name = 'popup'; + $action = new \popup_action('click', $link, $name, $options); + $respinfo .= $this->renderer->action_link( + $link, + null, + $action, + ['title' => $linkname], + $htmlicon + ) . ' '; + } + $respinfo .= get_string('respondent', 'questionnaire') . ': ' . $ruser . ''; + if ($q->survey_is_public()) { + $coursename = \mod_questionnaire\local\db\response_record::get_course_fullname((int)$rid) ?? ''; + $respinfo .= ' ' . get_string('course') . ': ' . $coursename; + } + $respinfo .= $groupname; + $respinfo .= $timesubmitted; + $this->page->add_to_page('respondentinfo', $this->renderer->respondent_info($respinfo)); + } + + if ($q->capabilities()->can_print_blank() && $blankquestionnaire && $section == 1) { + $linkname = ' ' . get_string('printblank', 'questionnaire'); + $title = get_string('printblanktooltip', 'questionnaire'); + $url = '/mod/questionnaire/print.php?qid=' . $q->id() . '&rid=0&' . + 'courseid=' . $q->courseid() . '&sec=1'; + $options = [ + 'menubar' => true, + 'location' => false, + 'scrollbars' => true, + 'resizable' => true, + 'height' => 600, + 'width' => 800, + 'title' => $title, + ]; + $name = 'popup'; + $link = new \moodle_url($url); + $action = new \popup_action('click', $link, $name, $options); + $class = "floatprinticon"; + $this->page->add_to_page( + 'printblank', + $this->renderer->action_link( + $link, + $linkname, + $action, + ['class' => $class, 'title' => $title], + new \pix_icon('t/print', $title) + ) + ); + } + if ($section == 1) { + $title = $q->surveytitle(); + if (!empty($title)) { + $this->page->add_to_page('title', format_string($title)); + } + $subtitle = $q->surveysubtitle(); + if (!empty($subtitle)) { + $this->page->add_to_page('subtitle', format_string($subtitle)); + } + $info = $q->surveyinfo(); + if ($info) { + $infotext = file_rewrite_pluginfile_urls( + $info, + 'pluginfile.php', + $q->context()->id, + 'mod_questionnaire', + 'info', + $q->surveyid() + ); + $this->page->add_to_page('addinfo', format_text($infotext, FORMAT_HTML, ['noclean' => true])); + } + } + + if ($message) { + $this->page->add_to_page( + 'message', + $this->renderer->notification($message, \core\output\notification::NOTIFY_ERROR) + ); + } + } + + /** + * Render the page-number footer for paginated surveys. + * + * @param questionnaire $q + * @param int $section + * @param int $numsections + * @return void + */ + public function print_survey_end(questionnaire $q, $section, $numsections): void { + if (!$q->pages_autonumbered()) { + return; + } + if ($numsections > 1) { + $a = new \stdClass(); + $a->page = $section; + $a->totpages = $numsections; + $this->page->add_to_page( + 'pageinfo', + $this->renderer->container( + get_string('pageof', 'questionnaire', $a) . '  ', + 'surveyPage' + ) + ); + } + } + + /** + * Render the current section's questions for survey completion. + * + * @param questionnaire $q + * @param \stdClass $formdata + * @param int $section + * @param string $message + * @return bool|void + */ + public function survey_render(questionnaire $q, &$formdata, $section = 1, $message = '') { + if (empty($section)) { + $section = 1; + } + $questionsbysec = $q->questions_by_section_all(); + $numsections = count($questionsbysec); + if ($section > $numsections) { + $formdata->sec = $numsections; + $this->page->add_to_page( + 'notifications', + $this->renderer->notification(get_string('finished', 'questionnaire'), \core\output\notification::NOTIFY_WARNING) + ); + return false; + } + + $hasrequired = $q->survey()->has_required($section); + + $i = 0; + if ($section > 1) { + for ($j = 2; $j <= $section; $j++) { + foreach ($questionsbysec[$j - 1] as $question) { + if ($question->typeid() < QUESPAGEBREAK) { + $i++; + } + } + } + } + + $this->print_survey_start($q, $message, $section, $numsections, $hasrequired, '', true); + if ($q->use_progressbar() && count($questionsbysec) > 1) { + $this->page->add_to_page( + 'progressbar', + $this->renderer->render_progress_bar($section, $questionsbysec) + ); + } + if (key_exists($section, $questionsbysec)) { + foreach ($questionsbysec[$section] as $question) { + if ($question->is_numbered()) { + $i++; + } + $formdata->questionnaire_id = $q->id(); + if (isset($formdata->rid) && !empty($formdata->rid)) { + $q->add_response($formdata->rid); + } else { + $q->responses()->add_response_from_formdata($formdata); + } + $this->page->add_to_page( + 'questions', + $this->renderer->question_output( + $question, + ($q->responses()->get_response($formdata->rid) ?? []), + $i, + null, + [], + $q + ) + ); + } + } + + $this->print_survey_end($q, $section, $numsections); + } + + /** + * Fill the page with the save-progress confirmation, including a resume link when applicable. + * + * @param questionnaire $q + * @return void + */ + public function goto_saved(questionnaire $q): void { + global $CFG, $USER; + + $resumesurvey = get_string('resumesurvey', 'questionnaire'); + $savedprogress = get_string('savedprogress', 'questionnaire', '' . $resumesurvey . ''); + + $this->page->add_to_page( + 'notifications', + $this->renderer->notification($savedprogress, \core\output\notification::NOTIFY_SUCCESS) + ); + $this->page->add_to_page( + 'respondentinfo', + $this->renderer->homelink( + $CFG->wwwroot . '/course/view.php?id=' . $q->courseid(), + get_string('backto', 'moodle', $q->course()->fullname) + ) + ); + + if ( + $q->resume() + && $q->user_access_messages($USER->id, true) === null + && $q->capabilities()->user_can_take($USER->id) + && $q->questions() + && $q->user_has_saved_response($USER->id) + ) { + $this->page->add_to_page( + 'respondentinfo', + $this->renderer->homelink( + $CFG->wwwroot . '/mod/questionnaire/complete.php?' . + 'id=' . $q->coursemodule()->id . '&resume=1', + $resumesurvey + ) + ); + } + } + + /** + * Render or redirect to the thank-you screen after a submission. + * + * @param questionnaire $q + * @return void + */ + public function goto_thankyou(questionnaire $q): void { + global $USER; + + $thankurl = $q->survey()->thankspage(); + $thankhead = $q->survey()->thankhead(); + $thankbody = $q->survey()->thankbody(); + + if (!empty($thankurl)) { + if (!headers_sent()) { + header("Location: $thankurl"); + exit; + } + echo ' + + + '; + exit; + } + if (empty($thankhead)) { + $thankhead = get_string('thank_head', 'questionnaire'); + } + $questionsbysec = $q->questions_by_section_all(); + if ($q->use_progressbar() && count($questionsbysec) > 1) { + $this->page->add_to_page( + 'progressbar', + $this->renderer->render_progress_bar(count($questionsbysec) + 1, $questionsbysec) + ); + } + $this->page->add_to_page('title', format_string($thankhead)); + $this->page->add_to_page( + 'addinfo', + format_text( + file_rewrite_pluginfile_urls( + $thankbody, + 'pluginfile.php', + $q->context()->id, + 'mod_questionnaire', + 'thankbody', + $q->surveyid() + ), + FORMAT_HTML, + ['noclean' => true] + ) + ); + if ($q->capabilities()->can_read_own_responses()) { + $url = new \moodle_url( + 'myreport.php', + [ + 'id' => $q->coursemodule()->id, + 'instance' => $q->coursemodule()->instance, + 'user' => $USER->id, + 'byresponse' => 0, + 'action' => 'vresp', + ] + ); + $this->page->add_to_page('continue', $this->renderer->single_button($url, get_string('continue'))); + } else { + $url = new \moodle_url('/course/view.php', ['id' => $q->courseid()]); + $this->page->add_to_page('continue', $this->renderer->single_button($url, get_string('continue'))); + } + } +} diff --git a/classes/output/tabs.php b/classes/output/tabs.php new file mode 100644 index 000000000..557ea50a7 --- /dev/null +++ b/classes/output/tabs.php @@ -0,0 +1,373 @@ +. + +/** + * Tabbed-bar rendering for mod_questionnaire staff and learner views. + * + * @package mod_questionnaire + * @copyright 2026 Mike Churchward (mike.churchward@poetopensource.org) + * @author Mike Churchward + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace mod_questionnaire\output; + +use mod_questionnaire\questionnaire; +use moodle_url; +use tabobject; + +/** + * Builds the questionnaire activity's tabbed navigation and adds it to the page. + * + * Replaces the legacy procedural tabs.php include. Each call site supplies the + * literal tab name it owns, so the current tab is no longer carried between + * requests via $SESSION->questionnaire->current_tab. + */ +class tabs { + /** + * Constructor. + * + * @param questionnaire $questionnaire Active questionnaire domain object. + * @param string $currenttab Literal name of the tab owned by the current entry point. + * @param int|null $currentgroupid Active group filter (defaults to 0 when not supplied). + * @param int|null $rid Response id for deleteresp tab (when applicable). + */ + public function __construct( + /** @var questionnaire Active questionnaire domain object. */ + private readonly questionnaire $questionnaire, + /** @var string Literal name of the tab owned by the current entry point. */ + private readonly string $currenttab, + /** @var int|null Active group filter (defaults to 0 when not supplied). */ + private readonly ?int $currentgroupid = null, + /** @var int|null Response id for deleteresp tab (when applicable). */ + private readonly ?int $rid = null, + ) { + } + + /** + * Build the tab rows and append the rendered HTML to the page's tabsarea slot. + * + * @param object $page Templatable page that exposes add_to_page('tabsarea', $html). + */ + public function render(object $page): void { + global $CFG, $USER; + + $questionnaire = $this->questionnaire; + $currenttab = $this->currenttab; + $currentgroupid = $this->currentgroupid ?? 0; + $rid = $this->rid; + + $tabs = []; + $row = []; + $inactive = []; + $activated = []; + + $owner = $questionnaire->is_survey_owner(); + if ($questionnaire->capabilities()->can_manage_questionnaire() && $owner) { + $row[] = new tabobject( + 'settings', + $CFG->wwwroot . htmlspecialchars('/mod/questionnaire/qsettings.php?' . 'id=' . $questionnaire->coursemodule()->id), + get_string('advancedsettings') + ); + } + + if ($questionnaire->capabilities()->can_edit_questions() && $owner) { + $row[] = new tabobject( + 'questions', + $CFG->wwwroot . htmlspecialchars('/mod/questionnaire/questions.php?' . 'id=' . $questionnaire->coursemodule()->id), + get_string('questions', 'questionnaire') + ); + } + + if ($questionnaire->capabilities()->can_edit_questions() && $owner) { + $row[] = new tabobject( + 'feedback', + $CFG->wwwroot . htmlspecialchars('/mod/questionnaire/feedback.php?' . 'id=' . $questionnaire->coursemodule()->id), + get_string('feedback') + ); + } + + if ($questionnaire->capabilities()->can_preview() && $owner) { + if (!empty($questionnaire->questions())) { + $previewurl = $CFG->wwwroot . htmlspecialchars( + '/mod/questionnaire/preview.php?id=' . $questionnaire->coursemodule()->id + ); + $row[] = new tabobject('preview', $previewurl, get_string('preview_label', 'questionnaire')); + } + } + + $usernumresp = $questionnaire->count_submissions($USER->id); + + if ($questionnaire->capabilities()->can_read_own_responses() && ($usernumresp > 0)) { + $argstr = 'instance=' . $questionnaire->id() + . '&user=' . $USER->id . '&group=' . $currentgroupid; + if ($usernumresp == 1) { + $argstr .= '&byresponse=1&action=vresp'; + $yourrespstring = get_string('yourresponse', 'questionnaire'); + } else { + $yourrespstring = get_string('yourresponses', 'questionnaire'); + } + $row[] = new tabobject( + 'myreport', + $CFG->wwwroot . htmlspecialchars('/mod/questionnaire/myreport.php?' . $argstr), + $yourrespstring + ); + + if ($usernumresp > 1 && in_array($currenttab, ['mysummary', 'mybyresponse', 'myvall', 'mydownloadcsv'])) { + $inactive[] = 'myreport'; + $activated[] = 'myreport'; + $row2 = []; + $argstr2 = $argstr . '&action=summary'; + $row2[] = new tabobject( + 'mysummary', + $CFG->wwwroot . htmlspecialchars('/mod/questionnaire/myreport.php?' . $argstr2), + get_string('summary', 'questionnaire') + ); + $argstr2 = $argstr . '&byresponse=1&action=vresp'; + $row2[] = new tabobject( + 'mybyresponse', + $CFG->wwwroot . htmlspecialchars('/mod/questionnaire/myreport.php?' . $argstr2), + get_string('viewindividualresponse', 'questionnaire') + ); + $argstr2 = $argstr . '&byresponse=0&action=vall&group=' . $currentgroupid; + $row2[] = new tabobject( + 'myvall', + $CFG->wwwroot . htmlspecialchars('/mod/questionnaire/myreport.php?' . $argstr2), + get_string('myresponses', 'questionnaire') + ); + if ($questionnaire->capabilities()->can_download_responses()) { + $argstr2 = $argstr . '&action=dwnpg'; + $link = $CFG->wwwroot . htmlspecialchars('/mod/questionnaire/report.php?' . $argstr2); + $row2[] = new tabobject('mydownloadcsv', $link, get_string('downloadtextformat', 'questionnaire')); + } + } else if (in_array($currenttab, ['mybyresponse', 'mysummary'])) { + $inactive[] = 'myreport'; + $activated[] = 'myreport'; + } + } + + $numresp = $questionnaire->count_submissions(); + + // If questionnaire is set to separate groups, prevent user who is not member of any group + // to view All responses. + $canviewgroups = true; + $groupmode = groups_get_activity_groupmode($questionnaire->coursemodule(), $questionnaire->course()); + if ($groupmode == 1) { + $canviewgroups = groups_has_membership($questionnaire->coursemodule(), $USER->id); + } + $canviewallgroups = has_capability('moodle/site:accessallgroups', $questionnaire->context()); + $grouplogic = $canviewallgroups || $canviewgroups; + $resplogic = ($numresp > 0); + + if ($questionnaire->capabilities()->can_view_all_responses_anytime($grouplogic, $resplogic)) { + $argstr = 'instance=' . $questionnaire->id(); + $row[] = new tabobject( + 'allreport', + $CFG->wwwroot . htmlspecialchars('/mod/questionnaire/report.php?' . $argstr . '&action=vall'), + get_string('viewallresponses', 'questionnaire') + ); + if ( + in_array( + $currenttab, + [ + 'vall', + 'vresp', + 'valldefault', + 'vallasort', + 'vallarsort', + 'deleteall', + 'downloadcsv', + 'vrespsummary', + 'individualresp', + 'printresp', + 'deleteresp', + ] + ) + ) { + $inactive[] = 'allreport'; + $activated[] = 'allreport'; + if ($currenttab == 'vrespsummary' || $currenttab == 'valldefault') { + $inactive[] = 'vresp'; + } + $row2 = []; + $argstr2 = $argstr . '&action=vall&group=' . $currentgroupid; + $row2[] = new tabobject( + 'vall', + $CFG->wwwroot . htmlspecialchars('/mod/questionnaire/report.php?' . $argstr2), + get_string('summary', 'questionnaire') + ); + if ($questionnaire->capabilities()->can_view_single_response()) { + $argstr2 = $argstr . '&byresponse=1&action=vresp&group=' . $currentgroupid; + $row2[] = new tabobject( + 'vrespsummary', + $CFG->wwwroot . htmlspecialchars('/mod/questionnaire/report.php?' . $argstr2), + get_string('viewbyresponse', 'questionnaire') + ); + if ($currenttab == 'individualresp' || $currenttab == 'deleteresp') { + $argstr2 = $argstr . '&byresponse=1&action=vresp'; + $row2[] = new tabobject( + 'vresp', + $CFG->wwwroot . htmlspecialchars('/mod/questionnaire/report.php?' . $argstr2), + get_string('viewindividualresponse', 'questionnaire') + ); + } + } + } + if (in_array($currenttab, ['valldefault', 'vallasort', 'vallarsort', 'deleteall', 'downloadcsv'])) { + $activated[] = 'vall'; + $row3 = []; + + $argstr2 = $argstr . '&action=vall&group=' . $currentgroupid; + $row3[] = new tabobject( + 'valldefault', + $CFG->wwwroot . htmlspecialchars('/mod/questionnaire/report.php?' . $argstr2), + get_string('order_default', 'questionnaire') + ); + if ($currenttab != 'downloadcsv' && $currenttab != 'deleteall') { + $argstr2 = $argstr . '&action=vallasort&group=' . $currentgroupid; + $row3[] = new tabobject( + 'vallasort', + $CFG->wwwroot . htmlspecialchars('/mod/questionnaire/report.php?' . $argstr2), + get_string('order_ascending', 'questionnaire') + ); + $argstr2 = $argstr . '&action=vallarsort&group=' . $currentgroupid; + $row3[] = new tabobject( + 'vallarsort', + $CFG->wwwroot . htmlspecialchars('/mod/questionnaire/report.php?' . $argstr2), + get_string('order_descending', 'questionnaire') + ); + } + if ($questionnaire->capabilities()->can_delete_responses()) { + $argstr2 = $argstr . '&action=delallresp&group=' . $currentgroupid; + $row3[] = new tabobject( + 'deleteall', + $CFG->wwwroot . htmlspecialchars('/mod/questionnaire/report.php?' . $argstr2), + get_string('deleteallresponses', 'questionnaire') + ); + } + + if ($questionnaire->capabilities()->can_download_responses()) { + $argstr2 = $argstr . '&action=dwnpg&group=' . $currentgroupid; + $link = $CFG->wwwroot . htmlspecialchars('/mod/questionnaire/report.php?' . $argstr2); + $row3[] = new tabobject('downloadcsv', $link, get_string('downloadtextformat', 'questionnaire')); + } + } + + if (in_array($currenttab, ['individualresp', 'deleteresp'])) { + $inactive[] = 'vresp'; + if ($currenttab != 'deleteresp') { + $activated[] = 'vresp'; + } + if ($questionnaire->capabilities()->can_delete_responses()) { + $argstr2 = $argstr . '&action=dresp&rid=' . $rid . '&individualresponse=1'; + $row2[] = new tabobject( + 'deleteresp', + $CFG->wwwroot . htmlspecialchars('/mod/questionnaire/report.php?' . $argstr2), + get_string('deleteresp', 'questionnaire') + ); + } + } + } else if ( + $questionnaire->capabilities() + ->can_view_all_responses_with_restrictions($usernumresp, $grouplogic, $resplogic) + ) { + $argstr = 'instance=' . $questionnaire->id() . '&sid=' . $questionnaire->surveyid(); + $allreporturl = $CFG->wwwroot . htmlspecialchars( + '/mod/questionnaire/report.php?' . $argstr . '&action=vall&group=' . $currentgroupid + ); + $row[] = new tabobject( + 'allreport', + $allreporturl, + get_string('viewallresponses', 'questionnaire') + ); + if (in_array($currenttab, ['valldefault', 'vallasort', 'vallarsort', 'deleteall', 'downloadcsv'])) { + $inactive[] = 'vall'; + $activated[] = 'vall'; + $row2 = []; + $argstr2 = $argstr . '&action=vall&group=' . $currentgroupid; + $row2[] = new tabobject( + 'valldefault', + $CFG->wwwroot . htmlspecialchars('/mod/questionnaire/report.php?' . $argstr2), + get_string('summary', 'questionnaire') + ); + $inactive[] = $currenttab; + $activated[] = $currenttab; + $row3 = []; + $argstr2 = $argstr . '&action=vall&group=' . $currentgroupid; + $row3[] = new tabobject( + 'valldefault', + $CFG->wwwroot . htmlspecialchars('/mod/questionnaire/report.php?' . $argstr2), + get_string('order_default', 'questionnaire') + ); + $argstr2 = $argstr . '&action=vallasort&group=' . $currentgroupid; + $row3[] = new tabobject( + 'vallasort', + $CFG->wwwroot . htmlspecialchars('/mod/questionnaire/report.php?' . $argstr2), + get_string('order_ascending', 'questionnaire') + ); + $argstr2 = $argstr . '&action=vallarsort&group=' . $currentgroupid; + $row3[] = new tabobject( + 'vallarsort', + $CFG->wwwroot . htmlspecialchars('/mod/questionnaire/report.php?' . $argstr2), + get_string('order_descending', 'questionnaire') + ); + if ($questionnaire->capabilities()->can_delete_responses()) { + $argstr2 = $argstr . '&action=delallresp'; + $row2[] = new tabobject( + 'deleteall', + $CFG->wwwroot . htmlspecialchars('/mod/questionnaire/report.php?' . $argstr2), + get_string('deleteallresponses', 'questionnaire') + ); + } + + if ($questionnaire->capabilities()->can_download_responses()) { + $argstr2 = $argstr . '&action=dwnpg'; + $link = htmlspecialchars('/mod/questionnaire/report.php?' . $argstr2); + $row2[] = new tabobject('downloadcsv', $link, get_string('downloadtextformat', 'questionnaire')); + } + if (count($row2) <= 1) { + $currenttab = 'allreport'; + } + } + } + + if ($questionnaire->capabilities()->can_view_single_response() && ($canviewallgroups || $canviewgroups)) { + $nonrespondenturl = new moodle_url( + '/mod/questionnaire/show_nonrespondents.php', + ['id' => $questionnaire->coursemodule()->id] + ); + $row[] = new tabobject( + 'nonrespondents', + $nonrespondenturl->out(), + get_string('show_nonrespondents', 'questionnaire') + ); + } + + if ((count($row) > 1) || (!empty($row2) && (count($row2) > 1))) { + $tabs[] = $row; + + if (!empty($row2) && (count($row2) > 1)) { + $tabs[] = $row2; + } + + if (!empty($row3) && (count($row3) > 1)) { + $tabs[] = $row3; + } + + $page->add_to_page('tabsarea', print_tabs($tabs, $currenttab, $inactive, $activated, true)); + } + } +} diff --git a/classes/privacy/provider.php b/classes/privacy/provider.php index 04c0bd53b..d00c89dbd 100644 --- a/classes/privacy/provider.php +++ b/classes/privacy/provider.php @@ -21,6 +21,7 @@ use core_privacy\local\request\userlist; use core_privacy\local\request\approved_contextlist; use core_privacy\local\request\approved_userlist; +use mod_questionnaire\questionnaire; /** * Contains class mod_questionnaire\privacy\provider @@ -52,47 +53,47 @@ public static function get_metadata(collection $collection): collection { ], 'privacy:metadata:questionnaire_response'); $collection->add_database_table('questionnaire_response_bool', [ - 'response_id' => 'privacy:metadata:questionnaire_response_bool:response_id', - 'question_id' => 'privacy:metadata:questionnaire_response_bool:question_id', - 'choice_id' => 'privacy:metadata:questionnaire_response_bool:choice_id', + 'responseid' => 'privacy:metadata:questionnaire_response_bool:response_id', + 'questionid' => 'privacy:metadata:questionnaire_response_bool:question_id', + 'choiceid' => 'privacy:metadata:questionnaire_response_bool:choice_id', ], 'privacy:metadata:questionnaire_response_bool'); $collection->add_database_table('questionnaire_response_date', [ - 'response_id' => 'privacy:metadata:questionnaire_response_date:response_id', - 'question_id' => 'privacy:metadata:questionnaire_response_date:question_id', + 'responseid' => 'privacy:metadata:questionnaire_response_date:response_id', + 'questionid' => 'privacy:metadata:questionnaire_response_date:question_id', 'response' => 'privacy:metadata:questionnaire_response_date:response', ], 'privacy:metadata:questionnaire_response_date'); $collection->add_database_table('questionnaire_response_other', [ - 'response_id' => 'privacy:metadata:questionnaire_response_other:response_id', - 'question_id' => 'privacy:metadata:questionnaire_response_other:question_id', - 'choice_id' => 'privacy:metadata:questionnaire_response_other:choice_id', + 'responseid' => 'privacy:metadata:questionnaire_response_other:response_id', + 'questionid' => 'privacy:metadata:questionnaire_response_other:question_id', + 'choiceid' => 'privacy:metadata:questionnaire_response_other:choice_id', 'response' => 'privacy:metadata:questionnaire_response_other:response', ], 'privacy:metadata:questionnaire_response_other'); $collection->add_database_table('questionnaire_response_rank', [ - 'response_id' => 'privacy:metadata:questionnaire_response_rank:response_id', - 'question_id' => 'privacy:metadata:questionnaire_response_rank:question_id', - 'choice_id' => 'privacy:metadata:questionnaire_response_rank:choice_id', - 'rank' => 'privacy:metadata:questionnaire_response_rank:rankvalue', + 'responseid' => 'privacy:metadata:questionnaire_response_rank:response_id', + 'questionid' => 'privacy:metadata:questionnaire_response_rank:question_id', + 'choiceid' => 'privacy:metadata:questionnaire_response_rank:choice_id', + 'rankvalue' => 'privacy:metadata:questionnaire_response_rank:rankvalue', ], 'privacy:metadata:questionnaire_response_rank'); $collection->add_database_table('questionnaire_response_text', [ - 'response_id' => 'privacy:metadata:questionnaire_response_text:response_id', - 'question_id' => 'privacy:metadata:questionnaire_response_text:question_id', + 'responseid' => 'privacy:metadata:questionnaire_response_text:response_id', + 'questionid' => 'privacy:metadata:questionnaire_response_text:question_id', 'response' => 'privacy:metadata:questionnaire_response_text:response', ], 'privacy:metadata:questionnaire_response_text'); $collection->add_database_table('questionnaire_resp_multiple', [ - 'response_id' => 'privacy:metadata:questionnaire_resp_multiple:response_id', - 'question_id' => 'privacy:metadata:questionnaire_resp_multiple:question_id', - 'choice_id' => 'privacy:metadata:questionnaire_resp_multiple:choice_id', + 'responseid' => 'privacy:metadata:questionnaire_resp_multiple:response_id', + 'questionid' => 'privacy:metadata:questionnaire_resp_multiple:question_id', + 'choiceid' => 'privacy:metadata:questionnaire_resp_multiple:choice_id', ], 'privacy:metadata:questionnaire_resp_multiple'); $collection->add_database_table('questionnaire_resp_single', [ - 'response_id' => 'privacy:metadata:questionnaire_resp_single:response_id', - 'question_id' => 'privacy:metadata:questionnaire_resp_single:question_id', - 'choice_id' => 'privacy:metadata:questionnaire_resp_single:choice_id', + 'responseid' => 'privacy:metadata:questionnaire_resp_single:response_id', + 'questionid' => 'privacy:metadata:questionnaire_resp_single:question_id', + 'choiceid' => 'privacy:metadata:questionnaire_resp_single:choice_id', ], 'privacy:metadata:questionnaire_resp_single'); return $collection; @@ -158,8 +159,7 @@ public static function get_users_in_context(userlist $userlist) { * @param approved_contextlist $contextlist The approved contexts to export information for. */ public static function export_user_data(approved_contextlist $contextlist) { - global $DB, $CFG; - require_once($CFG->dirroot . '/mod/questionnaire/questionnaire.class.php'); + global $DB; if (empty($contextlist->count())) { return; @@ -188,7 +188,7 @@ public static function export_user_data(approved_contextlist $contextlist) { $responsedata = []; $responses = $DB->get_recordset_sql($sql, $params); foreach ($responses as $response) { - // If we've moved to a new choice, then write the last choice data and reinit the choice data array. + // If we've moved to a new instance, write the previous instance's data and reinitialise. if ($lastcmid != $response->cmid) { if (!empty($responsedata)) { $context = \context_module::instance($lastcmid); @@ -200,9 +200,7 @@ public static function export_user_data(approved_contextlist $contextlist) { } $responsedata = []; $lastcmid = $response->cmid; - $course = $DB->get_record("course", ["id" => $response->qcourse]); - $cm = get_coursemodule_from_instance("questionnaire", $response->qid, $course->id); - $questionnaire = new \questionnaire($course, $cm, $response->qid, null); + $questionnaire = questionnaire::from_instanceid($response->qid); } $responsedata['responses'][] = [ 'complete' => (($response->complete == 'y') ? get_string('yes') : get_string('no')), @@ -229,25 +227,20 @@ public static function export_user_data(approved_contextlist $contextlist) { * @param context $context Context to delete data from. */ public static function delete_data_for_all_users_in_context(\context $context) { - global $DB; - if (!($context instanceof \context_module)) { return; } - if (!$cm = get_coursemodule_from_id('questionnaire', $context->instanceid)) { + if (!$cmrecord = get_coursemodule_from_id('questionnaire', $context->instanceid)) { return; } - if (!($questionnaire = $DB->get_record('questionnaire', ['id' => $cm->instance]))) { - return; - } - - if ($responses = $DB->get_recordset('questionnaire_response', ['questionnaireid' => $questionnaire->id])) { - self::delete_responses($responses); + $questionnaire = questionnaire::from_cm(\cm_info::create($cmrecord)); + $qresponses = $questionnaire->responses(); + $responses = $qresponses->get_responses(); + foreach ($responses as $response) { + $qresponses->delete_response($response); } - $responses->close(); - $DB->delete_records('questionnaire_response', ['questionnaireid' => $questionnaire->id]); } /** @@ -256,8 +249,6 @@ public static function delete_data_for_all_users_in_context(\context $context) { * @param approved_contextlist $contextlist The approved contexts and user information to delete information for. */ public static function delete_data_for_user(approved_contextlist $contextlist) { - global $DB; - if (empty($contextlist->count())) { return; } @@ -267,25 +258,16 @@ public static function delete_data_for_user(approved_contextlist $contextlist) { if (!($context instanceof \context_module)) { continue; } - if (!$cm = get_coursemodule_from_id('questionnaire', $context->instanceid)) { + if (!$cmrecord = get_coursemodule_from_id('questionnaire', $context->instanceid)) { continue; } - if (!($questionnaire = $DB->get_record('questionnaire', ['id' => $cm->instance]))) { - continue; + $questionnaire = questionnaire::from_cm(\cm_info::create($cmrecord)); + $qresponses = $questionnaire->responses(); + $responses = $qresponses->get_responses($userid); + foreach ($responses as $response) { + $qresponses->delete_response($response); } - - if ( - $responses = $DB->get_recordset( - 'questionnaire_response', - ['questionnaireid' => $questionnaire->id, - 'userid' => $userid] - ) - ) { - self::delete_responses($responses); - } - $responses->close(); - $DB->delete_records('questionnaire_response', ['questionnaireid' => $questionnaire->id, 'userid' => $userid]); } } @@ -296,42 +278,18 @@ public static function delete_data_for_user(approved_contextlist $contextlist) { * information for. */ public static function delete_data_for_users(approved_userlist $userlist) { - global $DB; - $context = $userlist->get_context(); - if (!$cm = get_coursemodule_from_id('questionnaire', $context->instanceid)) { - return; - } - if (!($questionnaire = $DB->get_record('questionnaire', ['id' => $cm->instance]))) { + if (!$cmrecord = get_coursemodule_from_id('questionnaire', $context->instanceid)) { return; } - [$userinsql, $userinparams] = $DB->get_in_or_equal($userlist->get_userids(), SQL_PARAMS_NAMED); - $params = array_merge(['questionnaireid' => $questionnaire->id], $userinparams); - $select = 'questionnaireid = :questionnaireid AND userid ' . $userinsql; - if ($responses = $DB->get_recordset_select('questionnaire_response', $select, $params)) { - self::delete_responses($responses); - } - $responses->close(); - $DB->delete_records_select('questionnaire_response', $select, $params); - } - - /** - * Helper function to delete all the response records for a recordset array of responses. - * - * @param \moodle_recordset $responses The list of response records to delete for. - */ - private static function delete_responses(\moodle_recordset $responses) { - global $DB; - - foreach ($responses as $response) { - $DB->delete_records('questionnaire_response_bool', ['response_id' => $response->id]); - $DB->delete_records('questionnaire_response_date', ['response_id' => $response->id]); - $DB->delete_records('questionnaire_resp_multiple', ['response_id' => $response->id]); - $DB->delete_records('questionnaire_response_other', ['response_id' => $response->id]); - $DB->delete_records('questionnaire_response_rank', ['response_id' => $response->id]); - $DB->delete_records('questionnaire_resp_single', ['response_id' => $response->id]); - $DB->delete_records('questionnaire_response_text', ['response_id' => $response->id]); + $questionnaire = questionnaire::from_cm(\cm_info::create($cmrecord)); + $qresponses = $questionnaire->responses(); + foreach ($userlist->get_userids() as $userid) { + $responses = $qresponses->get_responses($userid); + foreach ($responses as $response) { + $qresponses->delete_response($response); + } } } } diff --git a/classes/questionnaire.php b/classes/questionnaire.php new file mode 100644 index 000000000..1f5eb11f7 --- /dev/null +++ b/classes/questionnaire.php @@ -0,0 +1,1580 @@ +. + +namespace mod_questionnaire; + +use cm_info; +use mod_questionnaire\local\db\dependency_record; +use mod_questionnaire\local\db\questionnaire_record; +use mod_questionnaire\local\db\survey_record; +use mod_questionnaire\local\question\question; +use mod_questionnaire\local\question_navigator; +use mod_questionnaire\local\response\questionnaire_responses; +use mod_questionnaire\reporter; +use mod_questionnaire\local\response\response; +use mod_questionnaire\survey; +use context_module; +use stdClass; + +/** + * The main class used to access and manage the questionnaire module. + * + * @package mod_questionnaire + * @copyright 2025 onward Mike Churchward (mike.churchward@poetopensource.org) + * @author Mike Churchward + * @license http://www.gnu.org/copyleft/gpl.html GNU Public License + */ +class questionnaire { + // Response-frequency (qtype field) constants. + + /** @var int Response frequency: unlimited attempts. */ + public const QTYPE_UNLIMITED = 0; + /** @var int Response frequency: one attempt only. */ + public const QTYPE_ONCE = 1; + /** @var int Response frequency: once per day. */ + public const QTYPE_DAILY = 2; + /** @var int Response frequency: once per week. */ + public const QTYPE_WEEKLY = 3; + /** @var int Response frequency: once per month. */ + public const QTYPE_MONTHLY = 4; + + // Student response-view (respview field) constants. + + /** @var int Response visibility: students never see other responses. */ + public const RESPVIEW_NEVER = 0; + /** @var int Response visibility: students see responses after they have answered. */ + public const RESPVIEW_WHENANSWERED = 1; + /** @var int Response visibility: students see responses after the questionnaire closes. */ + public const RESPVIEW_WHENCLOSED = 2; + /** @var int Response visibility: students always see other responses. */ + public const RESPVIEW_ALWAYS = 3; + + // Other internal constants. + + /** @var int Default number of rows per pagination page. */ + private const DEFAULT_PAGE_COUNT = 20; + /** @var string URL parameter name for the permanent-delete confirmation action. */ + public const CONFIRM_DELETE_PERMANENTLY = 'confirmdelpermanentlyq'; + /** @var string URL parameter name for the question-restore action. */ + public const RESTORE_PARAM = 'restoreq'; + + /** @var questionnaire_record|null The module record instance. */ + protected ?questionnaire_record $modulerecord = null; + + /** @var survey|null The survey domain object for this questionnaire. */ + protected ?survey $survey = null; + + /** @var cm_info|null The course module info object for this questionnaire. */ + protected ?cm_info $coursemodule = null; + + /** @var context_module|null The context for this questionnaire. */ + protected ?context_module $context = null; + + /** @var stdClass The course record for this questionnaire. */ + protected stdClass $course; + + /** @var question_navigator|null Lazy-loaded navigator for page and dependency traversal. */ + private ?question_navigator $navigator = null; + + /** @var reporter|null Lazy-loaded reporter for CSV export and response analysis. */ + private ?reporter $reporter = null; + + /** @var questionnaire_responses|null Lazy-loaded responses handler; cached so in-memory state persists. */ + private ?questionnaire_responses $responseshandler = null; + + /** @var capabilities|null Lazy-loaded permission/eligibility helper. */ + private ?capabilities $capabilities = null; + + /** @var submission_controller|null Lazy-loaded controller for in-progress submission flow. */ + private ?submission_controller $submission = null; + + /** @var string Course-module idnumber, used by gradebook. Set by callers that need it. */ + public string $cmidnumber = ''; + + /** @var int Course id, set by callers that need it directly on the object. */ + public int $courseid = 0; + + /** + * Construct from a questionnaire instance id, optionally with a pre-loaded persistent and cm. + * + * Pass all arguments null to construct an "empty" instance for survey-only mode; + * the from_survey() factory uses this path to populate a survey without a module + * instance or course module (e.g. previewing template/public surveys before + * attaching them to a course). + * + * @param int|null $mid Instance id (questionnaire.id). + * @param int|null $cmid Course module id, if already known. + * @param \cm_info|null $cm Optional pre-loaded cm_info object. + */ + public function __construct(?int $mid = null, ?int $cmid = null, ?cm_info $cm = null) { + if (empty($mid) && empty($cmid) && empty($cm)) { + return; + } + + if (!empty($mid)) { + $this->modulerecord = new questionnaire_record($mid); + } + if (!empty($cm)) { + $this->coursemodule = clone($cm); + } else if (!empty($cmid)) { + $this->coursemodule = cm_info::create( + get_coursemodule_from_id('questionnaire', $cmid, 0, false, MUST_EXIST) + ); + } + + if (empty($this->modulerecord)) { + $this->modulerecord = new questionnaire_record($this->coursemodule->instance); + } + + if (empty($this->coursemodule)) { + $this->coursemodule = \cm_info::create( + get_coursemodule_from_instance('questionnaire', $this->modulerecord->get('id'), 0, false, MUST_EXIST) + ); + } + + $this->context = context_module::instance($this->coursemodule->id); + $this->course = get_course($this->modulerecord->get('course')); + $sid = $this->modulerecord->get('sid'); + $this->survey = survey::from_sid((int) $sid, $this->context); + } + + /** + * Return a questionnaire instance from an activity instance id. + * + * @param int $instanceid questionnaire.id + * @return self + */ + public static function from_instanceid(int $instanceid): self { + return new self($instanceid); + } + + /** + * Return a questionnaire instance from a course module id. + * + * @param int $cmid course_modules.id + * @return self + */ + public static function from_cmid(int $cmid): self { + return new self(null, $cmid); + } + + /** + * Return a questionnaire instance from a cm_info object. + * + * @param \cm_info $cm Course module info object. + * @return self + */ + public static function from_cm(\cm_info $cm): self { + return new self(null, null, $cm); + } + + /** + * Return a survey-only instance: no module record, no course module, no module context. + * + * Used by preview.php to preview template or public surveys before they are + * attached to a course module. The returned instance has id() == 0 and + * coursemodule() == null; context() falls back to the course context. + * + * @param int $sid Survey id. + * @param \stdClass $course Course record the survey is being previewed under. + * @return self + */ + public static function from_survey(int $sid, \stdClass $course): self { + $instance = new self(); + $instance->course = $course; + $instance->survey = survey::from_sid($sid); + return $instance; + } + + /** + * Get the id of the questionnaire instance. Returns 0 in survey-only mode. + * + * @return int + */ + public function id(): int { + return $this->modulerecord?->get('id') ?? 0; + } + + /** + * Get the name of the questionnaire. Falls back to the survey title in survey-only mode. + * + * @return string + */ + public function name(): string { + return $this->modulerecord?->get('name') ?? ($this->survey?->title() ?? ''); + } + + /** + * Get the intro text of the questionnaire. + * + * @return string + */ + public function intro(): string { + return $this->modulerecord?->get('intro') ?? ''; + } + + /** + * Get the course id of the questionnaire. + * + * @return int + */ + public function courseid(): int { + return (int) ($this->modulerecord?->get('course') ?? $this->course->id); + } + + /** + * Get the course record of the questionnaire. + * + * @return stdClass + */ + public function course(): stdClass { + return $this->course; + } + + /** + * Whether the questionnaire should show a progress bar. + * + * @return bool + */ + public function use_progressbar(): bool { + return $this->modulerecord->get('progressbar') == 1; + } + + /** + * Get the course module info object. Returns null in survey-only mode. + * + * @return \cm_info|null + */ + public function coursemodule(): ?\cm_info { + return $this->coursemodule; + } + + /** + * Get the context for this questionnaire. Falls back to the course context in survey-only mode. + * + * @return \context + */ + public function context(): \context { + return $this->context ?? \context_course::instance($this->course->id); + } + + /** + * Get the survey domain object for this questionnaire. + * + * @return survey + */ + public function survey(): survey { + return $this->survey; + } + + /** + * Get the survey id linked to this questionnaire. + * + * @return int + */ + public function surveyid(): int { + return $this->survey->id(); + } + + /** + * Get the survey title. + * + * @return string + */ + public function surveytitle(): string { + return $this->survey->title(); + } + + /** + * Get the survey subtitle. + * + * @return string + */ + public function surveysubtitle(): string { + return $this->survey->subtitle(); + } + + /** + * Get the survey info/description. + * + * @return string + */ + public function surveyinfo(): string { + return $this->survey->info(); + } + + /** + * Get the grade value for this questionnaire. + * + * @return int + */ + public function grade(): int { + return $this->modulerecord->get('grade'); + } + + /** + * Get the response-frequency setting for this questionnaire. + * + * @return int One of the QTYPE_* constants. + */ + public function qtype(): int { + return (int) $this->modulerecord->get('qtype'); + } + + /** + * Get the response-view setting for this questionnaire. + * + * @return int One of the RESPVIEW_* constants. + */ + public function respview(): int { + return (int) $this->modulerecord->get('respview'); + } + + /** + * Get the respondent type setting for this questionnaire (e.g. 'fullname', 'anonymous'). + * + * @return string + */ + public function respondenttype(): string { + return $this->modulerecord?->get('respondenttype') ?? 'fullname'; + } + + /** + * Get the notification setting for this questionnaire (0 = none, 1 = simple, 2 = full). + * + * @return int + */ + public function notifications(): int { + return (int) $this->modulerecord->get('notifications'); + } + + /** + * Return true if this questionnaire allows resuming a saved response. + * + * @return bool + */ + public function resume(): bool { + return (bool) $this->modulerecord->get('resume'); + } + + /** + * Get all question objects for this questionnaire. + * + * @return question[] + */ + public function questions(): array { + return $this->survey->questions(); + } + + /** + * Reload questions from the database into this instance. + * + * @return void + */ + public function add_questions(): void { + $this->survey->add_questions(); + } + + /** + * Get question objects for a specific section. + * + * @param int $section 1-based section number. + * @return question[] + */ + public function questions_by_section(int $section = 1): array { + return $this->survey->questions_by_section($section); + } + + /** + * Get all question arrays organised by section (keyed by 1-based section number). + * + * @return array + */ + public function questions_by_section_all(): array { + return $this->survey->questions_by_section_all(); + } + + /** + * True if questions should be automatically numbered. + * + * @return bool + */ + public function questions_autonumbered(): bool { + $autonum = $this->modulerecord->get('autonum'); + return !empty($autonum) && ($autonum == 1 || $autonum == 3); + } + + /** + * Whether navigation (skip-logic) is enabled for this questionnaire. + * + * @return int 0 = disabled, 1 = enabled. + */ + public function navigate(): int { + return (int) $this->modulerecord->get('navigate'); + } + + /** + * True if pages should be automatically numbered. + * + * @return bool + */ + public function pages_autonumbered(): bool { + $autonum = $this->modulerecord->get('autonum'); + return !empty($autonum) && ($autonum == 2 || $autonum == 3); + } + + /** + * Return the navigator for page traversal and dependency inspection. + * + * @return question_navigator + */ + public function navigator(): question_navigator { + $this->navigator ??= new question_navigator($this->survey, $this->navigate() > 0); + return $this->navigator; + } + + /** + * Return the reporter for CSV export and response analysis. + * + * Render-path callers should pass the page-level renderer and templatable page; + * CSV-only callers (e.g. submission_notifier) may omit both arguments. + * + * @param \plugin_renderer_base|null $renderer Renderer used by render-path methods. + * @param object|null $page Templatable page used by render-path methods. + * @return reporter + */ + public function reporter(?\plugin_renderer_base $renderer = null, ?object $page = null): reporter { + $this->reporter ??= new reporter($this, $renderer, $page); + return $this->reporter; + } + + /** + * True if skip-logic is enabled and at least one question has a dependency condition. + * + * Kept as a delegation method because question.php receives either the legacy or new + * questionnaire class and calls this method on whichever it has. + * + * @return bool + */ + public function has_dependencies(): bool { + return $this->navigator()->has_dependencies(); + } + + /** + * Load display info for each dependency of a question (parent name, choice label, etc.). + * + * Kept as a delegation method because question.php receives either the legacy or new + * questionnaire class and calls this method on whichever it has. + * + * @param question $question + * @return bool + */ + public function load_parents(question $question): bool { + return $this->navigator()->load_parents($question); + } + + /** + * True if the questionnaire has an associated survey (is active). + * + * @return bool + */ + public function is_active(): bool { + return $this->survey->id() > 0; + } + + /** + * True if the questionnaire is currently open (past the open date, or no open date set). + * + * @return bool + */ + public function is_open(): bool { + $opendate = $this->modulerecord->get('opendate'); + return ($opendate > 0) ? ($opendate < time()) : true; + } + + /** + * True if the questionnaire is closed (past the close date). + * + * @return bool + */ + public function is_closed(): bool { + $closedate = $this->modulerecord->get('closedate'); + return ($closedate > 0) ? ($closedate < time()) : false; + } + + /** + * True if the questionnaire is configured to require submission for completion. + * + * @return bool + */ + public function completionsubmit(): bool { + return (bool) $this->modulerecord->get('completionsubmit'); + } + + /** + * True if the questionnaire collects anonymous responses. + * + * @return bool + */ + public function is_anonymous(): bool { + return $this->modulerecord->get('respondenttype') == 'anonymous'; + } + + /** + * True if the survey is marked as public (shareable across courses). + * + * @return bool + */ + public function survey_is_public(): bool { + return $this->survey->is_public(); + } + + /** + * True if the survey is a template. + * + * @return bool + */ + public function survey_is_template(): bool { + return $this->survey->is_template(); + } + + /** + * True if the survey is public and this questionnaire is in the owning course. + * + * @return bool + */ + public function survey_is_public_master(): bool { + return $this->survey->is_public_master($this->courseid()); + } + + /** + * True if the current course owns this survey (as opposed to using a shared public survey). + * + * @return bool + */ + public function is_survey_owner(): bool { + return $this->survey->is_owned_by_course($this->courseid()); + } + + /** + * True if the user has an in-progress (incomplete) saved response. + * + * @param int $userid + * @return bool + */ + public function user_has_saved_response(int $userid): bool { + return \mod_questionnaire\local\db\response_record::user_has_saved_response($this->id(), $userid); + } + + /** + * True if the given user has at least one complete (submitted) response for this questionnaire. + * + * @param int $userid + * @return bool + */ + public function user_has_submitted(int $userid): bool { + return (new questionnaire_responses($this))->response_exists($userid); + } + + /** + * Count the number of complete submissions for this questionnaire. + * + * @param int|false $userid Restrict to a specific user, or false for all users. + * @param int $groupid Restrict to a specific group, or 0 for all groups. + * @return int + */ + public function count_submissions($userid = false, int $groupid = 0): int { + return $this->survey_is_public_master() + ? \mod_questionnaire\local\db\response_record::count_complete_for_public_survey( + $this->surveyid(), + $userid, + $groupid + ) + : \mod_questionnaire\local\db\response_record::count_complete_for_questionnaire( + $this->id(), + $userid, + $groupid + ); + } + + /** + * Return information strings about how the questionnaire can be attempted. + * + * @return string[] + */ + public function view_information(): array { + $messages = []; + + switch ($this->modulerecord->get('qtype')) { + case self::QTYPE_UNLIMITED: + $typestring = get_string('unlimited', 'questionnaire'); + break; + case self::QTYPE_ONCE: + $typestring = get_string('once', 'questionnaire'); + break; + case self::QTYPE_DAILY: + $typestring = get_string('daily', 'questionnaire'); + break; + case self::QTYPE_WEEKLY: + $typestring = get_string('weekly', 'questionnaire'); + break; + case self::QTYPE_MONTHLY: + $typestring = get_string('monthly', 'questionnaire'); + break; + default: + $typestring = ''; + break; + } + $messages[] = get_string('attemptsallowed', 'questionnaire', $typestring); + + if ($this->is_open() && !$this->is_closed()) { + if ($this->modulerecord->get('opendate') > 0) { + $messages[] = get_string('openedat', 'questionnaire', userdate($this->modulerecord->get('opendate'))); + } + if ($this->modulerecord->get('closedate') > 0) { + $messages[] = get_string('closesat', 'questionnaire', userdate($this->modulerecord->get('closedate'))); + } + } + + return $messages; + } + + /** + * Render an array of info messages as HTML paragraphs. + * + * @param array $messages + * @return string + */ + public function access_messages(array $messages): string { + $output = ''; + foreach ($messages as $message) { + $output .= \html_writer::tag('p', $message) . "\n"; + } + return $output; + } + + /** + * Return a human-readable message explaining why the user cannot access this questionnaire, + * or null if access is permitted. + * + * @param int $userid 0 for current user. + * @param bool $asnotification Unused — retained for API compatibility. + * @return string|null + */ + public function user_access_messages(int $userid = 0, bool $asnotification = false): ?string { + global $USER; + + if ($userid == 0) { + $userid = $USER->id; + } + + if (!$this->is_active()) { + $msg = $this->capabilities()->can_manage_questionnaire() ? 'removenotinuse' : 'notavail'; + return get_string($msg, 'questionnaire'); + } + if ($this->survey_is_template()) { + return get_string('templatenotviewable', 'questionnaire'); + } + if (!$this->is_open()) { + return get_string('notopen', 'questionnaire', userdate($this->modulerecord->get('opendate'))); + } + if ($this->is_closed()) { + return get_string('closed', 'questionnaire', userdate($this->modulerecord->get('closedate'))); + } + if (!$this->capabilities()->user_is_eligible($userid)) { + return get_string('noteligible', 'questionnaire'); + } + if (!$this->capabilities()->user_can_take($userid)) { + switch ($this->modulerecord->get('qtype')) { + case self::QTYPE_DAILY: + $msgstring = ' ' . get_string('today', 'questionnaire'); + break; + case self::QTYPE_WEEKLY: + $msgstring = ' ' . get_string('thisweek', 'questionnaire'); + break; + case self::QTYPE_MONTHLY: + $msgstring = ' ' . get_string('thismonth', 'questionnaire'); + break; + default: + $msgstring = ''; + break; + } + return get_string('alreadyfilled', 'questionnaire', $msgstring); + } + + return null; + } + + // Behavior methods — expose internal constants as a public API. + + /** + * Return the default number of rows shown per pagination page. + * + * @return int + */ + public static function default_page_count(): int { + return self::DEFAULT_PAGE_COUNT; + } + + /** + * Return the URL parameter name used to confirm permanent question deletion. + * + * @return string + */ + public static function confirm_delete_param(): string { + return self::CONFIRM_DELETE_PERMANENTLY; + } + + /** + * Return the URL parameter name used to restore a soft-deleted question. + * + * @return string + */ + public static function restore_param(): string { + return self::RESTORE_PARAM; + } + + // Navigation / display / hook methods (delegated from lib.php). + + /** + * Adds module specific settings to the settings block. + * + * @param \settings_navigation $settings The settings navigation object. + * @param \navigation_node $questionnairenode The node to add module settings to. + * @return void + */ + public function extend_settings_navigation( + \settings_navigation $settings, + \navigation_node $questionnairenode + ): void { + global $USER; + + $individualresponse = optional_param('individualresponse', false, PARAM_INT); + $rid = optional_param('rid', false, PARAM_INT); // Response id. + $currentgroupid = optional_param('group', 0, PARAM_INT); // Group id. + + $cm = $settings->get_page()->cm; + $context = $cm->context; + $cmid = $cm->id; + $course = $settings->get_page()->course; + $courseid = $course->id; + + // Treat an unlinked or orphaned survey row as "owned" — same fallback as the legacy code. + $owner = ($this->survey()->id() == 0) ? true : $this->survey()->is_owned_by_course((int) $courseid); + + // On view page, currentgroupid is not yet sent as an optional_param, so get it. + $groupmode = groups_get_activity_groupmode($cm, $course); + if ($groupmode > 0 && $currentgroupid == 0) { + $currentgroupid = groups_get_activity_group($this->coursemodule()); + if (!groups_is_member($currentgroupid, $USER->id)) { + $currentgroupid = 0; + } + } + + // We want to add these new nodes after the Edit settings node, and before the + // Locally assigned roles node. Of course, both of those are controlled by capabilities. + $keys = $questionnairenode->get_children_key_list(); + $beforekey = null; + $i = array_search('modedit', $keys); + if (($i === false) && array_key_exists(0, $keys)) { + $beforekey = $keys[0]; + } else if (array_key_exists($i + 1, $keys)) { + $beforekey = $keys[$i + 1]; + } + + if (has_capability('mod/questionnaire:manage', $context) && $owner) { + $url = '/mod/questionnaire/qsettings.php'; + $node = \navigation_node::create( + get_string('advancedsettings'), + new \moodle_url($url, ['id' => $cmid]), + \navigation_node::TYPE_SETTING, + null, + 'advancedsettings', + new \pix_icon('t/edit', '') + ); + $questionnairenode->add_node($node, $beforekey); + } + + if (has_capability('mod/questionnaire:editquestions', $context) && $owner) { + $url = '/mod/questionnaire/questions.php'; + $node = \navigation_node::create( + get_string('questions', 'questionnaire'), + new \moodle_url($url, ['id' => $cmid]), + \navigation_node::TYPE_SETTING, + null, + 'questions', + new \pix_icon('t/edit', '') + ); + $questionnairenode->add_node($node, $beforekey); + } + + if (has_capability('mod/questionnaire:editquestions', $context) && $owner) { + $url = '/mod/questionnaire/feedback.php'; + $node = \navigation_node::create( + get_string('feedback', 'questionnaire'), + new \moodle_url($url, ['id' => $cmid]), + \navigation_node::TYPE_SETTING, + null, + 'feedback', + new \pix_icon('t/edit', '') + ); + $questionnairenode->add_node($node, $beforekey); + } + + if (has_capability('mod/questionnaire:preview', $context)) { + $url = '/mod/questionnaire/preview.php'; + $node = \navigation_node::create( + get_string('preview_label', 'questionnaire'), + new \moodle_url($url, ['id' => $cmid]), + \navigation_node::TYPE_SETTING, + null, + 'preview', + new \pix_icon('t/preview', '') + ); + $questionnairenode->add_node($node, $beforekey); + } + + if ($this->capabilities()->user_can_take($USER->id)) { + $url = '/mod/questionnaire/complete.php'; + if ($this->user_has_saved_response($USER->id)) { + $args = ['id' => $cmid, 'resume' => 1]; + $text = get_string('resumesurvey', 'questionnaire'); + } else { + $args = ['id' => $cmid]; + $text = get_string('answerquestions', 'questionnaire'); + } + $node = \navigation_node::create( + $text, + new \moodle_url($url, $args), + \navigation_node::TYPE_SETTING, + null, + '', + new \pix_icon('i/info', 'answerquestions') + ); + $questionnairenode->add_node($node, $beforekey); + } + $usernumresp = $this->count_submissions($USER->id); + + if ($this->capabilities()->can_read_own_responses() && ($usernumresp > 0)) { + $url = '/mod/questionnaire/myreport.php'; + + if ($usernumresp > 1) { + $urlargs = [ + 'instance' => $this->id(), + 'userid' => $USER->id, + 'byresponse' => 0, + 'action' => 'summary', + 'group' => $currentgroupid, + ]; + $node = \navigation_node::create( + get_string('yourresponses', 'questionnaire'), + new \moodle_url($url, $urlargs), + \navigation_node::TYPE_SETTING, + null, + 'yourresponses' + ); + $myreportnode = $questionnairenode->add_node($node, $beforekey); + + $urlargs = [ + 'instance' => $this->id(), + 'userid' => $USER->id, + 'byresponse' => 0, + 'action' => 'summary', + 'group' => $currentgroupid, + ]; + $myreportnode->add(get_string('summary', 'questionnaire'), new \moodle_url($url, $urlargs)); + + $urlargs = [ + 'instance' => $this->id(), + 'userid' => $USER->id, + 'byresponse' => 1, + 'action' => 'vresp', + 'group' => $currentgroupid, + ]; + $byresponsenode = $myreportnode->add( + get_string('viewindividualresponse', 'questionnaire'), + new \moodle_url($url, $urlargs) + ); + + $urlargs = [ + 'instance' => $this->id(), + 'userid' => $USER->id, + 'byresponse' => 0, + 'action' => 'vall', + 'group' => $currentgroupid, + ]; + $myreportnode->add(get_string('myresponses', 'questionnaire'), new \moodle_url($url, $urlargs)); + if ($this->capabilities()->can_download_responses()) { + $urlargs = [ + 'instance' => $this->id(), + 'user' => $USER->id, + 'action' => 'dwnpg', + 'group' => $currentgroupid, + ]; + $myreportnode->add( + get_string('downloadtextformat', 'questionnaire'), + new \moodle_url( + '/mod/questionnaire/report.php', + $urlargs + ) + ); + } + } else { + $urlargs = [ + 'instance' => $this->id(), + 'userid' => $USER->id, + 'byresponse' => 1, + 'action' => 'vresp', + 'group' => $currentgroupid, + ]; + $node = \navigation_node::create( + get_string('yourresponse', 'questionnaire'), + new \moodle_url( + $url, + $urlargs + ), + \navigation_node::TYPE_SETTING, + null, + 'yourresponse' + ); + $myreportnode = $questionnairenode->add_node($node, $beforekey); + } + } + + // If questionnaire is set to separate groups, prevent user who is not member of any group + // and is not a non-editing teacher to view All responses. + if ($this->capabilities()->can_view_all_responses($usernumresp)) { + $url = '/mod/questionnaire/report.php'; + $node = \navigation_node::create( + get_string('viewallresponses', 'questionnaire'), + new \moodle_url( + $url, + ['instance' => $this->id(), 'action' => 'vall'] + ), + \navigation_node::TYPE_SETTING, + null, + 'vall' + ); + $reportnode = $questionnairenode->add_node($node, $beforekey); + + if ($this->capabilities()->can_view_single_response()) { + $summarynode = $reportnode->add( + get_string('summary', 'questionnaire'), + new \moodle_url( + '/mod/questionnaire/report.php', + ['instance' => $this->id(), 'action' => 'vall'] + ) + ); + } else { + $summarynode = $reportnode; + } + $summarynode->add( + get_string('order_default', 'questionnaire'), + new \moodle_url( + '/mod/questionnaire/report.php', + ['instance' => $this->id(), 'action' => 'vall', 'group' => $currentgroupid] + ) + ); + $summarynode->add( + get_string('order_ascending', 'questionnaire'), + new \moodle_url( + '/mod/questionnaire/report.php', + ['instance' => $this->id(), 'action' => 'vallasort', 'group' => $currentgroupid] + ) + ); + $summarynode->add( + get_string('order_descending', 'questionnaire'), + new \moodle_url( + '/mod/questionnaire/report.php', + ['instance' => $this->id(), 'action' => 'vallarsort', 'group' => $currentgroupid] + ) + ); + + if ($this->capabilities()->can_delete_responses()) { + $summarynode->add( + get_string('deleteallresponses', 'questionnaire'), + new \moodle_url( + '/mod/questionnaire/report.php', + ['instance' => $this->id(), 'action' => 'delallresp', 'group' => $currentgroupid] + ) + ); + } + + if ($this->capabilities()->can_download_responses()) { + $summarynode->add( + get_string('downloadtextformat', 'questionnaire'), + new \moodle_url( + '/mod/questionnaire/report.php', + ['instance' => $this->id(), 'action' => 'dwnpg', 'group' => $currentgroupid] + ) + ); + } + if ($this->capabilities()->can_view_single_response()) { + $byresponsenode = $reportnode->add( + get_string('viewbyresponse', 'questionnaire'), + new \moodle_url( + '/mod/questionnaire/report.php', + ['instance' => $this->id(), 'action' => 'vresp', 'byresponse' => 1, 'group' => $currentgroupid] + ) + ); + + $byresponsenode->add( + get_string('view', 'questionnaire'), + new \moodle_url( + '/mod/questionnaire/report.php', + ['instance' => $this->id(), 'action' => 'vresp', 'byresponse' => 1, 'group' => $currentgroupid] + ) + ); + + if ($individualresponse) { + $byresponsenode->add( + get_string('deleteresp', 'questionnaire'), + new \moodle_url( + '/mod/questionnaire/report.php', + [ + 'instance' => $this->id(), + 'action' => 'dresp', + 'byresponse' => 1, + 'rid' => $rid, + 'group' => $currentgroupid, + 'individualresponse' => 1, + ] + ) + ); + } + } + } + + $canviewgroups = true; + $groupmode = groups_get_activity_groupmode($cm, $course); + if ($groupmode == 1) { + $canviewgroups = groups_has_membership($cm, $USER->id); + } + $canviewallgroups = has_capability('moodle/site:accessallgroups', $context); + if ($this->capabilities()->can_view_single_response() && ($canviewallgroups || $canviewgroups)) { + $url = '/mod/questionnaire/show_nonrespondents.php'; + $node = \navigation_node::create( + get_string('show_nonrespondents', 'questionnaire'), + new \moodle_url($url, ['id' => $cmid]), + \navigation_node::TYPE_SETTING, + null, + 'nonrespondents' + ); + $questionnairenode->add_node($node, $beforekey); + } + } + + /** + * Collects questionnaire activity since $timestart for the recent activity block. + * + * @param array $activities Accumulated list of activities; new ones are appended. + * @param int $index Current array index; incremented for each activity appended. + * @param int $timestart Unix timestamp — only activity after this is included. + * @param int $courseid Course to search within. + * @param int $cmid Course-module id of the questionnaire. + * @param int $userid Restrict to a single user (0 = all users). + * @param int $groupid Restrict to a group (0 = all groups). + * @return void + */ + public static function get_recent_mod_activity( + array &$activities, + int &$index, + int $timestart, + int $courseid, + int $cmid, + int $userid = 0, + int $groupid = 0 + ): void { + global $COURSE, $USER, $DB; + + if ($COURSE->id == $courseid) { + $course = $COURSE; + } else { + $course = $DB->get_record('course', ['id' => $courseid]); + } + + $modinfo = get_fast_modinfo($course); + + $cm = $modinfo->cms[$cmid]; + $questionnaire = self::from_cm($cm); + + $context = \context_module::instance($cm->id); + $grader = has_capability('mod/questionnaire:viewsingleresponse', $context); + + // If this is a copy of a public questionnaire whose original is located in another course, + // current user (teacher) cannot view responses. + if ($grader) { + // For a public questionnaire, look for the original public questionnaire that it is based on. + if (!$questionnaire->survey_is_public_master()) { + // For a public questionnaire, look for the original public questionnaire that it is based on. + $surveycourseid = $questionnaire->survey()->owning_courseid(); + $originalquestionnaire = questionnaire_record::get_for_survey_in_course( + $questionnaire->surveyid(), + $surveycourseid + ); + $cmoriginal = get_coursemodule_from_instance( + "questionnaire", + $originalquestionnaire->get('id'), + $surveycourseid + ); + $contextoriginal = \context_course::instance($surveycourseid, MUST_EXIST); + if (!has_capability('mod/questionnaire:viewsingleresponse', $contextoriginal)) { + $tmpactivity = new stdClass(); + $tmpactivity->type = 'questionnaire'; + $tmpactivity->cmid = $cm->id; + $tmpactivity->cannotview = true; + $tmpactivity->anonymous = false; + $activities[$index++] = $tmpactivity; + return; + } + } + } + + $params = []; + if ($userid) { + $userselect = "AND u.id = :userid"; + $params['userid'] = $userid; + } else { + $userselect = ''; + } + + if ($groupid) { + $groupselect = 'AND gm.groupid = :groupid'; + $groupjoin = 'JOIN {groups_members} gm ON gm.userid=u.id'; + $params['groupid'] = $groupid; + } else { + $groupselect = ''; + $groupjoin = ''; + } + + $params['timestart'] = $timestart; + $params['questionnaireid'] = $questionnaire->id(); + + $userfieldsapi = \core_user\fields::for_userpic(); + $ufields = $userfieldsapi->get_sql('u', false, '', 'useridagain', false)->selects; + if ( + !$attempts = $DB->get_records_sql( + " + SELECT qr.*, + {$ufields} + FROM {questionnaire_response} qr + JOIN {user} u ON u.id = qr.userid + $groupjoin + WHERE qr.submitted > :timestart + AND qr.questionnaireid = :questionnaireid + $userselect + $groupselect + ORDER BY qr.submitted ASC", + $params + ) + ) { + return; + } + + $accessallgroups = has_capability('moodle/site:accessallgroups', $context); + $viewfullnames = has_capability('moodle/site:viewfullnames', $context); + $groupmode = groups_get_activity_groupmode($cm, $course); + + $usersgroups = null; + $aname = format_string($cm->name, true); + $userattempts = []; + foreach ($attempts as $attempt) { + if ($questionnaire->respondenttype() != 'anonymous') { + if (!isset($userattempts[$attempt->lastname])) { + $userattempts[$attempt->lastname] = 1; + } else { + $userattempts[$attempt->lastname]++; + } + } + if ($attempt->userid != $USER->id) { + if (!$grader) { + // View complete individual responses permission required. + continue; + } + + if (($groupmode == SEPARATEGROUPS) && !$accessallgroups) { + if ($usersgroups === null) { + $usersgroups = groups_get_all_groups( + $course->id, + $attempt->userid, + $cm->groupingid + ); + if (is_array($usersgroups)) { + $usersgroups = array_keys($usersgroups); + } else { + $usersgroups = []; + } + } + if (!array_intersect($usersgroups, $modinfo->groups[$cm->id])) { + continue; + } + } + } + + $tmpactivity = new stdClass(); + + $tmpactivity->type = 'questionnaire'; + $tmpactivity->cmid = $cm->id; + $tmpactivity->cminstance = $cm->instance; + // Current user is admin - or teacher enrolled in original public course. + if (isset($cmoriginal)) { + $tmpactivity->cminstance = $cmoriginal->instance; + } + $tmpactivity->cannotview = false; + $tmpactivity->anonymous = false; + $tmpactivity->name = $aname; + $tmpactivity->sectionnum = $cm->sectionnum; + $tmpactivity->timestamp = $attempt->submitted; + $tmpactivity->groupid = $groupid; + if (isset($userattempts[$attempt->lastname])) { + $tmpactivity->nbattempts = $userattempts[$attempt->lastname]; + } + + $tmpactivity->content = new stdClass(); + $tmpactivity->content->attemptid = $attempt->id; + + $userfieldsapi = \core_user\fields::for_userpic(); + $allnamefields = $userfieldsapi->get_sql('', false, '', '', false)->selects; + $selects = str_replace(', ', ',', $allnamefields); + $userfields = explode(',', $selects); + $tmpactivity->user = new stdClass(); + foreach ($userfields as $userfield) { + if ($userfield == 'id') { + $tmpactivity->user->{$userfield} = $attempt->userid; + } else { + if (!empty($attempt->{$userfield})) { + $tmpactivity->user->{$userfield} = $attempt->{$userfield}; + } else { + $tmpactivity->user->{$userfield} = null; + } + } + } + if ($questionnaire->respondenttype() != 'anonymous') { + $tmpactivity->user->fullname = fullname($attempt, $viewfullnames); + } else { + $tmpactivity->user = ''; + unset($tmpactivity->user); + $tmpactivity->anonymous = true; + } + $activities[$index++] = $tmpactivity; + } + } + + /** + * Prints all users who have completed a specified questionnaire since a given time. + * + * @param object $activity Activity object from get_recent_mod_activity. + * @param int $courseid Course id. + * @param string $detail Not used but needed for compatibility. + * @param array $modnames Module names array. + * @return void Output is echoed. + */ + public static function print_recent_mod_activity( + object $activity, + int $courseid, + string $detail, + array $modnames + ): void { + global $OUTPUT; + + // If the questionnaire is "anonymous", then $activity->user won't have been set, so do not display respondent info. + if ($activity->anonymous) { + $stranonymous = ' (' . get_string('anonymous', 'questionnaire') . ')'; + $activity->nbattempts = ''; + } else { + $stranonymous = ''; + } + // Current user cannot view responses to public questionnaire. + if ($activity->cannotview) { + $strcannotview = get_string('cannotviewpublicresponses', 'questionnaire'); + } + echo \html_writer::start_tag('div'); + echo \html_writer::start_tag( + 'span', + [ + 'class' => 'clearfix', + 'style' => 'margin-top:0px; background-color: white; display: inline-block;', + ] + ); + + if (!$activity->anonymous && !$activity->cannotview) { + echo \html_writer::tag( + 'div', + $OUTPUT->user_picture($activity->user, ['courseid' => $courseid]), + ['style' => 'float: left; padding-right: 10px;'] + ); + } + if (!$activity->cannotview) { + echo \html_writer::start_tag('div'); + echo \html_writer::start_tag('div'); + + $urlparams = [ + 'action' => 'vresp', + 'instance' => $activity->cminstance, + 'group' => $activity->groupid, + 'rid' => $activity->content->attemptid, + 'individualresponse' => 1, + ]; + + $context = \context_module::instance($activity->cmid); + if (has_capability('mod/questionnaire:viewsingleresponse', $context)) { + $report = 'report.php'; + } else { + $report = 'myreport.php'; + } + echo \html_writer::tag( + 'a', + get_string('response', 'questionnaire') . ' ' . $activity->nbattempts . $stranonymous, + ['href' => new \moodle_url('/mod/questionnaire/' . $report, $urlparams)] + ); + echo \html_writer::end_tag('div'); + } else { + echo \html_writer::start_tag('div'); + echo \html_writer::start_tag('div'); + echo \html_writer::tag('div', $strcannotview); + echo \html_writer::end_tag('div'); + } + if (!$activity->anonymous && !$activity->cannotview) { + $url = new \moodle_url('/user/view.php', ['course' => $courseid, 'id' => $activity->user->id]); + $name = $activity->user->fullname; + $link = \html_writer::link($url, $name); + echo \html_writer::start_tag('div', ['class' => 'user']); + echo $link . ' - ' . userdate($activity->timestamp); + echo \html_writer::end_tag('div'); + } + + echo \html_writer::end_tag('div'); + echo \html_writer::end_tag('span'); + echo \html_writer::end_tag('div'); + } + + /** + * Called after the activity and module have been created. Copies file areas if the questionnaire + * was created from another questionnaire survey. + * + * @param object $data Form data including coursemodule and optional copyid. + * @param object $course Course record. + * @return object The data object (unchanged). + */ + public static function coursemodule_edit_post_actions(object $data, object $course): object { + if (!empty($data->copyid)) { + $questionnaire = self::from_cmid((int)$data->coursemodule); + $oldquestionnaireid = questionnaire_record::get_for_survey((int) $data->copyid)?->get('id'); + $oldcm = get_coursemodule_from_instance('questionnaire', $oldquestionnaireid); + $oldquestionnaire = self::from_cmid((int)$oldcm->id); + $oldcontext = \context_module::instance($oldcm->id); + $newcontext = \context_module::instance($data->coursemodule); + $areas = $questionnaire->get_all_file_areas(); + $oldareas = $oldquestionnaire->get_all_file_areas(); + $fs = new \mod_questionnaire\file_storage(); + foreach ($areas as $area => $ids) { + if (is_array($ids)) { + $oldid = current($oldareas[$area]); + foreach ($ids as $id) { + $fs->copy_area_files_to_new_context( + $oldcontext->id, + $newcontext->id, + 'mod_questionnaire', + $area, + $oldid, + $id + ); + $oldid = next($oldareas[$area]); + } + } else { + $fs->copy_area_files_to_new_context( + $oldcontext->id, + $newcontext->id, + 'mod_questionnaire', + $area, + $oldareas[$area], + $ids + ); + } + } + } + + return $data; + } + + // Response-flow and utility methods. + + /** + * Return the questionnaire_responses handler for this questionnaire. + * + * The same instance is returned on every call so that in-memory response state + * (loaded via add_response / add_response_from_formdata) is visible to subsequent + * get_response calls within the same request. + * + * @return questionnaire_responses + */ + public function responses(): questionnaire_responses { + $this->responseshandler ??= new questionnaire_responses($this); + return $this->responseshandler; + } + + /** + * Return the capabilities helper for this questionnaire. + * + * @return capabilities + */ + public function capabilities(): capabilities { + $this->capabilities ??= new capabilities($this); + return $this->capabilities; + } + + /** + * Return the submission controller for page navigation and section saves. + * + * @return submission_controller + */ + public function submission(): submission_controller { + $this->submission ??= new submission_controller($this); + return $this->submission; + } + + /** + * Load a single response into the responses handler by response id. + * + * Convenience pass-through to questionnaire_responses::add_response(). + * + * @param int $responseid + */ + public function add_response(int $responseid): void { + $this->responses()->add_response($responseid); + } + + /** + * Load all submitted responses for a user into the responses handler. + * + * Convenience pass-through to questionnaire_responses::add_user_responses(). + * + * @param int|null $userid Defaults to current user when null. + */ + public function add_user_responses(?int $userid = null): void { + $this->responses()->add_user_responses($userid); + } + + /** + * Return a structured export of all answers for a given response id. + * + * Delegates to questionnaire_responses::get_structured_response(). Used by the + * privacy provider to export user data. + * + * @param int $rid The response id. + * @return array + */ + public function get_structured_response(int $rid): array { + return $this->responses()->get_structured_response($rid); + } + + /** + * Return all responses for this questionnaire, optionally filtered by user or group. + * + * @param int|false $userid Limit to this user, or false for all users. + * @param int $groupid Limit to this group (0 = no filter). + * @return array + */ + public function get_responses($userid = false, int $groupid = 0): array { + return $this->responses()->get_responses($userid, $groupid); + } + + /** + * Return the most recent incomplete response id for the given user, or 0 if none. + * + * @param int $userid + * @return int + */ + public function get_latest_responseid(int $userid): int { + return response::latest_incomplete($this->id(), $userid)?->id() ?? 0; + } + + /** + * Process a mobile-app submission and return a result array. + * + * Handles next/previous page navigation and final submission from the mobile app. + * Implementation delegates to responses() and submission() — this method itself + * is the public surface that externallib.php and output/mobile.php call. + * + * NOTE: This is the mobile-API contract. Do not rename, relocate, or change the + * signature without coordinating with the Moodle mobile app team and bumping the + * web-service version. See externallib.php::save_mobile_data and + * classes/output/mobile.php::mobile_view_activity_offline. + * + * @param int $userid + * @param int $sec Section (page) number. + * @param int $completed Whether the questionnaire has already been completed (1/0). + * @param int $rid Existing in-progress response id (0 if none). + * @param int $submit Whether the user is submitting (1/0). + * @param string $action 'nextpage', 'previouspage', or empty for a plain save. + * @param array $responses Key/value response data from the app. + * @return array Result array; may contain 'warnings', 'nextpagenum', or 'response' keys. + */ + public function save_mobile_data( + int $userid, + int $sec, + int $completed, + int $rid, + int $submit, + string $action, + array $responses + ): array { + $ret = []; + $response = $this->responses()->build_response_from_appdata((object)$responses, $sec, $rid); + $response->sec = $sec; + $response->rid = $rid; + + if ($action == 'nextpage') { + $result = $this->submission()->next_page_action($response, $userid); + if (is_string($result)) { + $ret['warnings'] = $result; + } else { + $ret['nextpagenum'] = $result; + } + } else if ($action == 'previouspage') { + $ret['nextpagenum'] = $this->submission()->previous_page_action($response, $userid); + } else if (!$completed) { + // If reviewing a completed questionnaire, don't insert a response. + $msg = $this->responses()->response_check_format( + $response->sec, + $response, + true, + true, + $this->survey->questions() + ); + if (empty($msg)) { + $rid = $this->responses()->response_insert($response, $userid); + } else { + $ret['warnings'] = $msg; + $ret['response'] = $response; + } + } + + if ($submit && (!isset($ret['warnings']) || empty($ret['warnings']))) { + $this->responses()->commit_submission_response($rid, $userid); + } + return $ret; + } + + /** + * Return an array describing all file areas used by this questionnaire's survey. + * + * @return array Keys are area names; values are either a single id or an array of ids. + */ + public function get_all_file_areas(): array { + return $this->survey->get_all_file_areas(); + } +} diff --git a/classes/questions_form.php b/classes/questions_form.php index 3dda7157c..dc73e9ef0 100644 --- a/classes/questions_form.php +++ b/classes/questions_form.php @@ -20,7 +20,6 @@ require_once($CFG->libdir . '/formslib.php'); -#[\AllowDynamicProperties] /** * The form definition class for questions. * @@ -30,13 +29,22 @@ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class questions_form extends \moodleform { + /** @var int|bool $moveq The id of the question being moved, or false. */ + protected $moveq = false; + + /** @var \plugin_renderer_base The plugin renderer used for image_url and dependency_html. */ + protected \plugin_renderer_base $renderer; + /** * The constructor. + * * @param mixed $action * @param bool $moveq + * @param \plugin_renderer_base $renderer The plugin renderer. */ - public function __construct($action, $moveq = false) { + public function __construct($action, $moveq, \plugin_renderer_base $renderer) { $this->moveq = $moveq; + $this->renderer = $renderer; return parent::__construct($action); } @@ -47,9 +55,9 @@ public function definition() { global $CFG, $questionnaire, $SESSION; global $DB; - $sid = $questionnaire->survey->id; + $sid = $questionnaire->surveyid(); $mform =& $this->_form; - $qidrestore = optional_param(QUESTIONNAIRE_RESTORE_PARAM, 0, PARAM_INT); + $qidrestore = optional_param(\mod_questionnaire\questionnaire::restore_param(), 0, PARAM_INT); $mform->addElement('header', 'questionhdr', get_string('addquestions', 'questionnaire')); $mform->addHelpButton('questionhdr', 'questiontypes', 'questionnaire'); @@ -59,11 +67,9 @@ public function definition() { $strmovehere = get_string('movehere'); $strposition = get_string('position', 'questionnaire'); - if (!isset($questionnaire->questions)) { - $questionnaire->questions = []; - } + $questions = $questionnaire->questions(); if ($this->moveq) { - $moveqposition = $questionnaire->questions[$this->moveq]->position; + $moveqposition = $questions[$this->moveq]->position(); } $pos = 0; @@ -74,24 +80,24 @@ public function definition() { // Get the names of each question type in the appropriate language. foreach ($qtypes as $key => $qtype) { // Do not allow "Page Break" to be selected as first element of a Questionnaire. - if (empty($questionnaire->questions) && ($qtype == 'Page Break')) { + if (empty($questions) && ($qtype == 'Page Break')) { unset($qtypes[$key]); } else { - $qtypes[$key] = questionnaire_get_type($key); + $qtypes[$key] = \mod_questionnaire\local\question_type::display_name($key); } } natsort($qtypes); $addqgroup = []; - $addqgroup[] =& $mform->createElement('select', 'type_id', '', $qtypes); + $addqgroup[] =& $mform->createElement('select', 'typeid', '', $qtypes); - // The 'sticky' type_id value for further new questions. - if (isset($SESSION->questionnaire->type_id)) { - $mform->setDefault('type_id', $SESSION->questionnaire->type_id); + // The 'sticky' typeid value for further new questions. + if (isset($SESSION->questionnaire->typeid)) { + $mform->setDefault('typeid', $SESSION->questionnaire->typeid); } $addqgroup[] =& $mform->createElement('submit', 'addqbutton', get_string('addselqtype', 'questionnaire')); - $questionnairehasdependencies = $questionnaire->has_dependencies(); + $questionnairehasdependencies = $questionnaire->navigator()->has_dependencies(); $mform->addGroup($addqgroup, 'addqgroup', '', ' ', false); @@ -111,8 +117,8 @@ public function definition() { // we must get now the parent and child positions. if ($questionnairehasdependencies) { - $parentpositions = questionnaire_get_parent_positions($questionnaire->questions); - $childpositions = questionnaire_get_child_positions($questionnaire->questions); + $parentpositions = survey::get_parent_positions($questions); + $childpositions = survey::get_child_positions($questions); } $mform->addElement('header', 'manageq', get_string('managequestions', 'questionnaire')); @@ -120,24 +126,23 @@ public function definition() { $mform->addElement('html', '
'); - foreach ($questionnaire->questions as $question) { + foreach ($questions as $question) { $manageqgroup = []; - $qid = $question->id; - $tid = $question->type_id; - $qtype = $question->type; - $required = $question->required; + $qid = $question->id(); + $tid = $question->typeid(); + $required = $question->required() ? 'y' : 'n'; // Get displayable list of parents for the questions in questions_form. if ($questionnairehasdependencies) { // TODO - Perhaps this should be a function called by the questionnaire after it loads all questions? - $questionnaire->load_parents($question); - $dependencies = $questionnaire->renderer->get_dependency_html($question->id, $question->dependencies); + $questionnaire->navigator()->load_parents($question); + $dependencies = $this->renderer->get_dependency_html($question->id(), $question->dependencies); } else { $dependencies = ''; } - $pos = $question->position; + $pos = $question->position(); // No page break in first position! if ($tid == QUESPAGEBREAK && $pos == 1) { @@ -152,7 +157,7 @@ public function definition() { ); } } - redirect($CFG->wwwroot . '/mod/questionnaire/questions.php?id=' . $questionnaire->cm->id); + redirect($CFG->wwwroot . '/mod/questionnaire/questions.php?id=' . $questionnaire->coursemodule()->id); } if ($question->is_numbered()) { @@ -160,22 +165,23 @@ public function definition() { } // Needed for non-English languages JR. - $qtype = '[' . questionnaire_get_type($tid) . ']'; - $content = ''; + $qtype = '[' . \mod_questionnaire\local\question_type::display_name($tid) . ']'; // If question text is "empty", i.e. 2 non-breaking spaces were inserted, do not display any question text. - if ($question->content == '

  

') { - $question->content = ''; + $displaycontent = $question->content(); + if ($displaycontent == '

') { + $displaycontent = ''; } + $content = ''; if ($tid != QUESPAGEBREAK) { // Needed to print potential media in question text. $content = format_text( file_rewrite_pluginfile_urls( - $question->content, + $displaycontent, 'pluginfile.php', $question->context->id, 'mod_questionnaire', 'question', - $question->id + $question->id() ), FORMAT_HTML, ['noclean' => true] @@ -183,7 +189,7 @@ public function definition() { } $moveqgroup = []; - $spacer = $questionnaire->renderer->image_url('spacer'); + $spacer = $this->renderer->image_url('spacer'); if (!$this->moveq) { if ($dependencies) { @@ -191,7 +197,7 @@ public function definition() { $mform->addElement('html', '
'); } else { $containerclass = "qn-container"; - if (isset($qidrestore) && $qidrestore == $question->id) { + if (isset($qidrestore) && $qidrestore == $question->id()) { $containerclass .= " restored-question"; } // Begin div qn-container. @@ -199,17 +205,17 @@ public function definition() { } $mextra = [ - 'value' => $question->id, + 'value' => $question->id(), 'alt' => $strmove, 'title' => $strmove, ]; $eextra = [ - 'value' => $question->id, + 'value' => $question->id(), 'alt' => get_string('edit', 'questionnaire'), 'title' => get_string('edit', 'questionnaire'), ]; $rextra = [ - 'value' => $question->id, + 'value' => $question->id(), 'alt' => $strremove, 'title' => $strremove, ]; @@ -218,9 +224,9 @@ public function definition() { $esrc = $spacer; $eextra = ['disabled' => 'disabled']; } else { - $esrc = $questionnaire->renderer->image_url('t/edit'); + $esrc = $this->renderer->image_url('t/edit'); } - $rsrc = $questionnaire->renderer->image_url('t/delete'); + $rsrc = $this->renderer->image_url('t/delete'); // Question numbers. $manageqgroup[] =& $mform->createElement( @@ -231,8 +237,8 @@ public function definition() { ); // Need to index by 'id' since IE doesn't return assigned 'values' for image inputs. - $manageqgroup[] =& $mform->createElement('static', 'opentag_' . $question->id, '', ''); - $msrc = $questionnaire->renderer->image_url('t/move'); + $manageqgroup[] =& $mform->createElement('static', 'opentag_' . $question->id(), '', ''); + $msrc = $this->renderer->image_url('t/move'); if ($questionnairehasdependencies) { // Do not allow moving parent question at position #1 to be moved down if it has a child at position < 4. @@ -241,9 +247,9 @@ public function definition() { $maxdown = $childpositions[$qid]; if ($maxdown < 4) { $strdisabled = get_string('movedisabled', 'questionnaire'); - $msrc = $questionnaire->renderer->image_url('t/block'); + $msrc = $this->renderer->image_url('t/block'); $mextra = [ - 'value' => $question->id, + 'value' => $question->id(), 'alt' => $strdisabled, 'title' => $strdisabled, ]; @@ -289,9 +295,9 @@ public function definition() { (!empty($previousquestiondependencies) && empty($nextquestiondependencies)) ) { $strdisabled = get_string('movedisabled', 'questionnaire'); - $msrc = $questionnaire->renderer->image_url('t/block'); + $msrc = $this->renderer->image_url('t/block'); $mextra = [ - 'value' => $question->id, + 'value' => $question->id(), 'alt' => $strdisabled, 'title' => $strdisabled, ]; @@ -300,7 +306,7 @@ public function definition() { $rsrc = $msrc; $strdisabled = get_string('deletedisabled', 'questionnaire'); $rextra = [ - 'value' => $question->id, + 'value' => $question->id(), 'alt' => $strdisabled, 'title' => $strdisabled, ]; @@ -312,35 +318,35 @@ public function definition() { } $manageqgroup[] =& $mform->createElement( 'image', - 'movebutton[' . $question->id . ']', + 'movebutton[' . $question->id() . ']', $msrc, $mextra ); - $manageqgroup[] =& $mform->createElement('image', 'editbutton[' . $question->id . ']', $esrc, $eextra); - $manageqgroup[] =& $mform->createElement('image', 'removebutton[' . $question->id . ']', $rsrc, $rextra); + $manageqgroup[] =& $mform->createElement('image', 'editbutton[' . $question->id() . ']', $esrc, $eextra); + $manageqgroup[] =& $mform->createElement('image', 'removebutton[' . $question->id() . ']', $rsrc, $rextra); if ($tid != QUESPAGEBREAK && $tid != QUESSECTIONTEXT && $tid != QUESSLIDER) { if ($required == 'y') { - $reqsrc = $questionnaire->renderer->image_url('t/stop'); + $reqsrc = $this->renderer->image_url('t/stop'); $strrequired = get_string('required', 'questionnaire'); } else { - $reqsrc = $questionnaire->renderer->image_url('t/go'); + $reqsrc = $this->renderer->image_url('t/go'); $strrequired = get_string('notrequired', 'questionnaire'); } $strrequired .= ' ' . get_string('clicktoswitch', 'questionnaire'); $reqextra = [ - 'value' => $question->id, + 'value' => $question->id(), 'alt' => $strrequired, 'title' => $strrequired, ]; $manageqgroup[] =& $mform->createElement( 'image', - 'requiredbutton[' . $question->id . ']', + 'requiredbutton[' . $question->id() . ']', $reqsrc, $reqextra ); } - $manageqgroup[] =& $mform->createElement('static', 'closetag_' . $question->id, '', ''); + $manageqgroup[] =& $mform->createElement('static', 'closetag_' . $question->id(), '', ''); } else { $manageqgroup[] =& $mform->createElement( 'static', @@ -368,30 +374,30 @@ public function definition() { } } - $typeid = $DB->get_field('questionnaire_question', 'type_id', ['id' => $this->moveq]); + $typeid = $DB->get_field('questionnaire_question', 'typeid', ['id' => $this->moveq]); if ($display) { // Do not move a page break to first position. if ($typeid == QUESPAGEBREAK && $pos == 1) { $manageqgroup[] =& $mform->createElement('static', 'qnums', '', ''); } else { - if ($this->moveq == $question->id) { + if ($this->moveq == $question->id()) { $moveqgroup[] =& $mform->createElement('cancel', 'cancelbutton', get_string('cancel')); } else { $mextra = [ - 'value' => $question->id, + 'value' => $question->id(), 'alt' => $strmove, 'title' => $strmovehere . ' (position ' . $pos . ')', ]; - $msrc = $questionnaire->renderer->image_url('movehere'); - $moveqgroup[] =& $mform->createElement('static', 'opentag_' . $question->id, '', ''); + $msrc = $this->renderer->image_url('movehere'); + $moveqgroup[] =& $mform->createElement('static', 'opentag_' . $question->id(), '', ''); $moveqgroup[] =& $mform->createElement( 'image', 'moveherebutton[' . $pos . ']', $msrc, $mextra ); - $moveqgroup[] =& $mform->createElement('static', 'closetag_' . $question->id, '', ''); + $moveqgroup[] =& $mform->createElement('static', 'closetag_' . $question->id(), '', ''); } } } else { @@ -399,15 +405,15 @@ public function definition() { $moveqgroup[] =& $mform->createElement('static', 'qnums', '', ''); } } - if ($question->name) { - $qname = '(' . $question->name . ')'; + if ($question->name()) { + $qname = '(' . $question->name() . ')'; } else { $qname = ''; } - $manageqgroup[] =& $mform->createElement('static', 'qinfo_' . $question->id, '', $qtype . ' ' . $qname); + $manageqgroup[] =& $mform->createElement('static', 'qinfo_' . $question->id(), '', $qtype . ' ' . $qname); if (!empty($dependencies)) { - $mform->addElement('static', 'qdepend_' . $question->id, '', $dependencies); + $mform->addElement('static', 'qdepend_' . $question->id(), '', $dependencies); } if ($question->is_numbered()) { @@ -420,7 +426,7 @@ public function definition() { $mform->addGroup($moveqgroup, 'moveqgroup', '', '', false); } if ($this->moveq) { - if ($this->moveq == $question->id && $display) { + if ($this->moveq == $question->id() && $display) { $mform->addElement('html', '
'); // Begin div qn-container. } else { $mform->addElement('html', '
'); // Begin div qn-container. @@ -430,7 +436,7 @@ public function definition() { if ($tid != QUESPAGEBREAK) { $mform->addElement( 'static', - 'qcontent_' . $question->id, + 'qcontent_' . $question->id(), '', $qnumber . '
' . $content . '
' ); @@ -446,14 +452,15 @@ public function definition() { $mform->addElement('header', 'deletionq', get_string('deletionquetions', 'questionnaire')); $mform->addHelpButton('deletionq', 'deletionquetions', 'questionnaire'); $mform->addElement('html', '
'); - if (isset($questionnaire->deletequestions)) { - $restoreimg = $questionnaire->renderer->image_url('i/up'); - $deleteimg = $questionnaire->renderer->image_url('t/delete'); - $rangetimecrontask = questionnaire_get_range_time_permanently(); - foreach ($questionnaire->deletequestions as $deletequestion) { + $deletequestions = $questionnaire->survey()->get_delete_questions(); + if (!empty($deletequestions)) { + $restoreimg = $this->renderer->image_url('i/up'); + $deleteimg = $this->renderer->image_url('t/delete'); + $rangetimecrontask = survey::question_deletion_duration(); + foreach ($deletequestions as $deletequestion) { $delquestiongroup = []; // Preparing deleted time to display time permanently question. - $timedeleted = $deletequestion->deleted ?? ""; + $timedeleted = $deletequestion->deleted() ?? ""; if ($rangetimecrontask == 0) { $timedeleted = get_string('recylebindisabled', 'questionnaire'); } else { @@ -466,17 +473,17 @@ public function definition() { } } $qtypeandname = []; - $qtypeandname['name'] = $deletequestion->name; - $qtypeandname['type'] = questionnaire_get_type($deletequestion->type_id); + $qtypeandname['name'] = $deletequestion->name(); + $qtypeandname['type'] = \mod_questionnaire\local\question_type::display_name($deletequestion->typeid()); $content = format_text( file_rewrite_pluginfile_urls( - $deletequestion->content, + $deletequestion->content(), 'pluginfile.php', $deletequestion->context->id, 'mod_questionnaire', 'question', - $deletequestion->id + $deletequestion->id() ), FORMAT_HTML, ['noclean' => true] @@ -484,48 +491,48 @@ public function definition() { $qnumber = '

NA

'; $restorextra = [ - 'value' => $deletequestion->id, + 'value' => $deletequestion->id(), 'alt' => get_string('restorebutton', 'questionnaire'), 'title' => get_string('restorebutton', 'questionnaire'), 'class' => 'mod_questionnaire_recycleq', ]; $deleleextra = [ - 'value' => $deletequestion->id, + 'value' => $deletequestion->id(), 'alt' => get_string('deletepermanentlybutton', 'questionnaire'), 'title' => get_string('deletepermanentlybutton', 'questionnaire'), 'class' => 'mod_questionnaire_recycleq', ]; $mform->addElement('html', '
'); // Begin div qn-container. - $delquestiongroup[] =& $mform->createElement('static', 'opentag_' . $deletequestion->id, '', ''); + $delquestiongroup[] =& $mform->createElement('static', 'opentag_' . $deletequestion->id(), '', ''); $delquestiongroup[] =& $mform->createElement( 'image', - 'restorebutton[' . $deletequestion->id . ']', + 'restorebutton[' . $deletequestion->id() . ']', $restoreimg, $restorextra ); $delquestiongroup[] =& $mform->createElement( 'image', - 'deletebutton[' . $deletequestion->id . ']', + 'deletebutton[' . $deletequestion->id() . ']', $deleteimg, $deleleextra ); - $delquestiongroup[] =& $mform->createElement('static', 'closetag_' . $deletequestion->id, '', ''); + $delquestiongroup[] =& $mform->createElement('static', 'closetag_' . $deletequestion->id(), '', ''); $delquestiongroup[] =& $mform->createElement( 'static', - 'qinfo_' . $deletequestion->id, + 'qinfo_' . $deletequestion->id(), '', get_string('questiontypeandname', 'questionnaire', $qtypeandname) ); $delquestiongroup[] =& $mform->createElement( 'static', - 'qinfo_' . $deletequestion->id, + 'qinfo_' . $deletequestion->id(), '', $timedeleted ); $mform->addGroup($delquestiongroup, 'delquestiongroup', '', ' ', false); $mform->addElement( 'static', - 'qcontent_' . $deletequestion->id, + 'qcontent_' . $deletequestion->id(), '', $qnumber . '
' . $content . '
' ); diff --git a/classes/report_actions.php b/classes/report_actions.php new file mode 100644 index 000000000..58e2c84f2 --- /dev/null +++ b/classes/report_actions.php @@ -0,0 +1,308 @@ +. + +namespace mod_questionnaire; + +use mod_questionnaire\local\db\response_record; + +/** + * Controller for staff-side report actions on report.php. + * + * Each method owns one of the four deletion-related action arms: + * confirm-delete-single, delete-single, confirm-delete-all, delete-all. + * + * Confirm methods render their own confirmation page (capability check, + * tabs, confirm dialog) and return; delete methods either redirect on + * success or throw a plugin moodle_exception on failure. + * + * @package mod_questionnaire + * @copyright 2026 Mike Churchward (mike.churchward@poetopensource.org) + * @author Mike Churchward + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class report_actions { + /** + * Constructor. + * + * @param questionnaire $questionnaire The active questionnaire. + * @param \plugin_renderer_base $renderer Plugin renderer for confirmation pages. + */ + public function __construct( + /** @var questionnaire The active questionnaire. */ + private readonly questionnaire $questionnaire, + /** @var \plugin_renderer_base Plugin renderer for confirmation pages. */ + private readonly \plugin_renderer_base $renderer, + ) { + } + + /** + * Render the "delete this response?" confirmation page. + * + * Side effects: sets $PAGE title/heading, echoes header/page/footer. + * + * @param object $page Templatable page object (reportpage). + * @param int $rid Response id to delete. + * @param int $currentgroupid Group filter context. + * @return void + */ + public function confirm_delete_response(object $page, int $rid, int $currentgroupid): void { + global $PAGE; + + require_capability('mod/questionnaire:deleteresponses', $this->questionnaire->context()); + + if (!$this->questionnaire->survey()) { + throw new \moodle_exception('surveynotexists', 'mod_questionnaire'); + } else if ($this->questionnaire->survey()->owning_courseid() != $this->questionnaire->course()->id) { + throw new \moodle_exception('surveyowner', 'mod_questionnaire'); + } else if (!$rid) { + throw new \moodle_exception('invalidresponse', 'mod_questionnaire'); + } else if (!($resp = response_record::get_or_null($rid))) { + throw new \moodle_exception('invalidresponserecord', 'mod_questionnaire'); + } + + $ruser = $this->describe_respondent((int)$resp->get('userid')); + $timesubmitted = '
' . get_string('submitted', 'questionnaire') . ' ' . userdate($resp->get('submitted')); + if ($this->questionnaire->respondenttype() == 'anonymous') { + $ruser = '- ' . get_string('anonymous', 'questionnaire') . ' -'; + $timesubmitted = ''; + } + + $PAGE->set_title(get_string('deletingresp', 'questionnaire')); + $PAGE->set_heading(format_string($this->questionnaire->course()->fullname)); + echo $this->renderer->header(); + + (new \mod_questionnaire\output\tabs($this->questionnaire, 'deleteresp', $currentgroupid, $rid))->render($page); + + $instance = $this->questionnaire->id(); + $msg = '
'; + $msg .= get_string('confirmdelresp', 'questionnaire', $ruser . $timesubmitted); + $msg .= '
'; + $urlyes = new \moodle_url('report.php', [ + 'action' => 'dvresp', + 'rid' => $rid, + 'individualresponse' => 1, + 'instance' => $instance, + 'group' => $currentgroupid, + ]); + $urlno = new \moodle_url('report.php', [ + 'action' => 'vresp', + 'instance' => $instance, + 'rid' => $rid, + 'individualresponse' => 1, + 'group' => $currentgroupid, + ]); + $buttonyes = new \single_button($urlyes, get_string('delete'), 'post'); + $buttonno = new \single_button($urlno, get_string('cancel'), 'get'); + $page->add_to_page('notifications', $this->renderer->confirm($msg, $buttonyes, $buttonno)); + echo $this->renderer->render($page); + echo $this->renderer->footer($this->questionnaire->course()); + } + + /** + * Execute the delete of a single response. Redirects on success, throws on failure. + * + * @param int $rid Response id to delete. + * @return void + */ + public function delete_response(int $rid): void { + global $CFG; + + require_capability('mod/questionnaire:deleteresponses', $this->questionnaire->context()); + + if (!$this->questionnaire->survey()) { + throw new \moodle_exception('surveynotexists', 'mod_questionnaire'); + } else if ($this->questionnaire->survey()->owning_courseid() != $this->questionnaire->course()->id) { + throw new \moodle_exception('surveyowner', 'mod_questionnaire'); + } else if (!$rid) { + throw new \moodle_exception('invalidresponse', 'mod_questionnaire'); + } else if (!($response = response_record::get_or_null($rid))) { + throw new \moodle_exception('invalidresponserecord', 'mod_questionnaire'); + } + + $responseuserid = (int)$response->get('userid'); + $instance = $this->questionnaire->id(); + + if ($this->questionnaire->responses()->delete_response($response->to_record())) { + if (!response_record::count_complete_for_questionnaire($instance)) { + $redirection = $CFG->wwwroot . '/mod/questionnaire/view.php?id=' . + $this->questionnaire->coursemodule()->id; + } else { + $redirection = $CFG->wwwroot . '/mod/questionnaire/report.php?action=vresp&instance=' . + $instance . '&byresponse=1'; + } + + $event = \mod_questionnaire\event\response_deleted::create([ + 'objectid' => $this->questionnaire->surveyid(), + 'context' => $this->questionnaire->context(), + 'courseid' => $this->questionnaire->courseid(), + 'relateduserid' => $responseuserid, + ]); + $event->trigger(); + + redirect($redirection); + } + + $ruser = ($this->questionnaire->respondenttype() == 'anonymous') + ? '- ' . get_string('anonymous', 'questionnaire') . ' -' + : $this->describe_respondent($responseuserid); + $link = new \moodle_url('/mod/questionnaire/report.php', [ + 'action' => 'vresp', + 'sid' => $this->questionnaire->surveyid(), + 'instance' => $instance, + 'byresponse' => '1', + ]); + throw new \moodle_exception( + 'couldnotdelrespby', + 'mod_questionnaire', + $link, + ['rid' => $rid, 'user' => $ruser] + ); + } + + /** + * Render the "delete all responses?" confirmation page. + * + * Renders nothing if there are no participants to delete (mirrors original arm behaviour). + * + * @param object $page Templatable page object (reportpage). + * @param int $groupmode Activity group mode (0/1/2). + * @param int $currentgroupid Active group filter. + * @param string $groupname Pre-formatted group label for the confirm message. + * @param array $respsallparticipants Responses to participants in scope. + * @return void + */ + public function confirm_delete_all_responses( + object $page, + int $groupmode, + int $currentgroupid, + string $groupname, + array $respsallparticipants + ): void { + global $PAGE; + + require_capability('mod/questionnaire:deleteresponses', $this->questionnaire->context()); + + if (empty($respsallparticipants)) { + return; + } + + $PAGE->set_title(get_string('deletingresp', 'questionnaire')); + $PAGE->set_heading(format_string($this->questionnaire->course()->fullname)); + echo $this->renderer->header(); + + (new \mod_questionnaire\output\tabs($this->questionnaire, 'deleteall', $currentgroupid))->render($page); + + $instance = $this->questionnaire->id(); + $msg = '
'; + if ($groupmode == 0) { + $msg .= get_string('confirmdelallresp', 'questionnaire'); + } else { + $msg .= get_string('confirmdelgroupresp', 'questionnaire', $groupname); + } + $msg .= '
'; + + $urlyes = new \moodle_url('report.php', [ + 'action' => 'dvallresp', + 'sid' => $this->questionnaire->surveyid(), + 'instance' => $instance, + 'group' => $currentgroupid, + ]); + $urlno = new \moodle_url('report.php', ['instance' => $instance, 'group' => $currentgroupid]); + $buttonyes = new \single_button($urlyes, get_string('delete'), 'post'); + $buttonno = new \single_button($urlno, get_string('cancel'), 'get'); + + $page->add_to_page('notifications', $this->renderer->confirm($msg, $buttonyes, $buttonno)); + echo $this->renderer->render($page); + echo $this->renderer->footer($this->questionnaire->course()); + } + + /** + * Execute the delete of all responses (optionally group-scoped). Redirects on success. + * + * @param int $groupmode Activity group mode. + * @param int $currentgroupid Active group filter. + * @param array $respsallparticipants Pre-computed all-participants responses. + * @return void + */ + public function delete_all_responses( + int $groupmode, + int $currentgroupid, + array $respsallparticipants + ): void { + global $CFG; + + require_capability('mod/questionnaire:deleteresponses', $this->questionnaire->context()); + + if (!$this->questionnaire->survey()) { + throw new \moodle_exception('surveynotexists', 'mod_questionnaire'); + } else if ($this->questionnaire->survey()->owning_courseid() != $this->questionnaire->course()->id) { + throw new \moodle_exception('surveyowner', 'mod_questionnaire'); + } + + if ($groupmode > 0 && $currentgroupid > 0) { + $resps = $this->questionnaire->get_responses(false, $currentgroupid) ?: []; + } else { + $resps = $respsallparticipants; + } + + $instance = $this->questionnaire->id(); + if (!empty($resps)) { + foreach ($resps as $response) { + $this->questionnaire->responses()->delete_response($response); + } + $sid = $this->questionnaire->surveyid(); + if (!$this->questionnaire->count_submissions()) { + $redirection = $CFG->wwwroot . '/mod/questionnaire/view.php?id=' . + $this->questionnaire->coursemodule()->id; + } else { + $redirection = $CFG->wwwroot . '/mod/questionnaire/report.php?action=vall&sid=' . + $sid . '&instance=' . $instance; + } + + $event = \mod_questionnaire\event\all_responses_deleted::create([ + 'objectid' => $instance, + 'anonymous' => $this->questionnaire->respondenttype() == 'anonymous', + 'context' => $this->questionnaire->context(), + ]); + $event->trigger(); + + redirect($redirection); + } + + $link = new \moodle_url('/mod/questionnaire/report.php', [ + 'action' => 'vall', + 'sid' => $this->questionnaire->surveyid(), + 'instance' => $instance, + ]); + throw new \moodle_exception('couldnotdelresp', 'mod_questionnaire', $link); + } + + /** + * Return a display string for a respondent (fullname, "unknown", or the empty marker). + * + * @param int $userid + * @return string + */ + private function describe_respondent(int $userid): string { + if (empty($userid)) { + return (string)$userid; + } + if ($user = \core_user::get_user($userid)) { + return fullname($user); + } + return '- ' . get_string('unknown', 'questionnaire') . ' -'; + } +} diff --git a/classes/report_downloader.php b/classes/report_downloader.php new file mode 100644 index 000000000..2378afd82 --- /dev/null +++ b/classes/report_downloader.php @@ -0,0 +1,213 @@ +. + +namespace mod_questionnaire; + +/** + * Controller for the staff-side download arms of report.php. + * + * Owns the two CSV / dataformat arms: + * - dwnpg: render the "download options" page (format selector + email options). + * - dfs: execute the download (stream it, email it, or redirect back with an error). + * + * Both methods end with exit() to match the original action-arm behaviour + * (so headers sent by dataformat::download_data are not followed by more + * output). Under PHPUnit, redirect() throws a moodle_exception, which is + * what the tests catch. + * + * @package mod_questionnaire + * @copyright 2026 Mike Churchward (mike.churchward@poetopensource.org) + * @author Mike Churchward + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class report_downloader { + /** + * Constructor. + * + * @param questionnaire $questionnaire The active questionnaire. + * @param \plugin_renderer_base $renderer Plugin renderer used for the options page. + */ + public function __construct( + /** @var questionnaire The active questionnaire. */ + private readonly questionnaire $questionnaire, + /** @var \plugin_renderer_base Plugin renderer for the options page. */ + private readonly \plugin_renderer_base $renderer, + ) { + } + + /** + * Render the "download options" page (dwnpg action arm). + * + * Side effects: sets $PAGE title/heading, echoes header / page / footer, + * triggers all_responses_saved_as_text, and exit()s. + * + * @param object $page Templatable report page. + * @param int $groupmode Activity groupmode (0 = none, 1 = separate, 2 = visible). + * @param int $currentgroupid Active group filter. + * @param array $questionnairegroups Available groups (id => obj) — used to label the selected group. + * @param int $user User filter applied to the download (0 = all users). + * @return void + */ + public function show_download_options( + object $page, + int $groupmode, + int $currentgroupid, + array $questionnairegroups, + int $user + ): void { + global $PAGE; + + require_capability('mod/questionnaire:downloadresponses', $this->questionnaire->context()); + + $course = $this->questionnaire->course(); + $instance = $this->questionnaire->id(); + + $PAGE->set_title(get_string('questionnairereport', 'questionnaire')); + $PAGE->set_heading(format_string($course->fullname)); + echo $this->renderer->header(); + + $tabname = empty($user) ? 'downloadcsv' : 'mydownloadcsv'; + (new \mod_questionnaire\output\tabs($this->questionnaire, $tabname, $currentgroupid))->render($page); + + $groupname = ''; + if ($groupmode > 0) { + if ($currentgroupid == 0) { + $groupname = get_string('allparticipants'); + } else { + $groupname = get_string('membersofselectedgroup', 'group') . ' ' . + get_string('group') . ' ' . $questionnairegroups[$currentgroupid]->name; + } + } + + $output = ''; + $output .= "

\n"; + $output .= \html_writer::tag('h2', get_string('downloadtextformat', 'questionnaire') + . ': ' . get_string('responses', 'questionnaire') . ' ' . + $groupname . $this->renderer->help_icon('downloadtextformat', 'questionnaire')); + $output .= $this->renderer->heading(get_string('textdownloadoptions', 'questionnaire'), 3); + $output .= $this->renderer->box_start(); + $downloadparams = [ + 'instance' => $instance, + 'user' => $user, + 'sid' => $this->questionnaire->surveyid(), + 'action' => 'dfs', + 'group' => $currentgroupid, + ]; + $extrafields = $this->renderer->render_from_template('mod_questionnaire/extrafields', []); + $output .= $this->renderer->download_dataformat_selector( + get_string('downloadtypes', 'questionnaire'), + 'report.php', + 'downloadformat', + $downloadparams, + $extrafields + ); + $output .= $this->renderer->box_end(); + + $page->add_to_page('respondentinfo', $output); + echo $this->renderer->render($page); + + echo $this->renderer->footer('none'); + + $event = \mod_questionnaire\event\all_responses_saved_as_text::create([ + 'objectid' => $instance, + 'context' => $this->questionnaire->context(), + 'courseid' => $this->questionnaire->courseid(), + 'other' => ['action' => 'dwnpg', 'instance' => $instance, 'currentgroupid' => $currentgroupid], + ]); + $event->trigger(); + + exit(); + } + + /** + * Generate and deliver a dataformat export (dfs action arm). + * + * Reads dataformat-related parameters directly from the request, since they + * are tightly coupled to the download form posted from show_download_options(). + * + * Three terminal paths: + * - Streams the file via core\dataformat::download_data (then exit()). + * - Emails the file via save_as_dataformat (then exit()). + * - Redirects back to the options page with an error if email was selected + * but no recipients were specified. + * + * @param object $page Templatable report page (the reporter needs it). + * @param int $currentgroupid Active group filter (echoed in redirect URLs). + * @param int $user User filter (passed through to generate_csv). + * @return void + */ + public function download_responses(object $page, int $currentgroupid, int $user): void { + global $CFG, $USER; + + require_capability('mod/questionnaire:downloadresponses', $this->questionnaire->context()); + + // Use the questionnaire name as the file name. Clean it and change any non-filename characters to '_'. + $name = clean_param($this->questionnaire->name(), PARAM_FILE); + $name = preg_replace('/[^A-Z0-9]+/i', '_', trim($name)); + + $choicecodes = optional_param('choicecodes', '0', PARAM_INT); + $choicetext = optional_param('choicetext', '0', PARAM_INT); + $showincompletes = optional_param('complete', '0', PARAM_INT); + $rankaverages = optional_param('rankaverages', '0', PARAM_INT); + $dataformat = optional_param('downloadformat', '', PARAM_ALPHA); + $emailroles = optional_param('emailroles', 0, PARAM_INT); + $emailextra = optional_param('emailextra', '', PARAM_RAW); + + $output = $this->questionnaire->reporter($this->renderer, $page)->generate_csv( + $currentgroupid, + '', + $user, + $choicecodes, + $choicetext, + $showincompletes, + $rankaverages + ); + + $columns = $output[0]; + unset($output[0]); + + $instance = $this->questionnaire->id(); + $emailreport = optional_param('emailreport', '', PARAM_ALPHA); + if (empty($emailreport)) { + \core\dataformat::download_data($name, $dataformat, $columns, $output); + exit(); + } + + if (get_config('questionnaire', 'allowemailreporting') && (!empty($emailroles) || !empty($emailextra))) { + require_once($CFG->dirroot . '/mod/questionnaire/savefileformat.php'); + $users = !empty($emailroles) + ? (new submission_notifier($this->questionnaire))->get_notifiable_users($USER->id) + : []; + $otheremails = explode(',', $emailextra); + if (!empty($users) || !empty($otheremails)) { + $thisurl = new \moodle_url( + 'report.php', + ['instance' => $instance, 'action' => 'dwnpg', 'group' => $currentgroupid] + ); + save_as_dataformat($name, $dataformat, $columns, $output, $users, $otheremails, $thisurl); + } + exit(); + } + + redirect( + new \moodle_url( + 'report.php', + ['instance' => $instance, 'action' => 'dwnpg', 'group' => $currentgroupid] + ), + get_string('emailsnotspecified', 'questionnaire') + ); + } +} diff --git a/classes/report_viewer.php b/classes/report_viewer.php new file mode 100644 index 000000000..4ba323a08 --- /dev/null +++ b/classes/report_viewer.php @@ -0,0 +1,431 @@ +. + +namespace mod_questionnaire; + +use mod_questionnaire\output\pdf_factory; + +/** + * Controller for the staff-side view arms of report.php. + * + * Owns the two render arms: + * - vall (and the two sort variants vallasort/vallarsort): summary view of all responses. + * - vresp (and the default action): per-respondent view, including PDF/print branches. + * + * Each method handles its own tab/scope setup, group filter UI, response collection, + * and the html / pdf rendering branches. Methods echo the page directly and return void + * (matching the original action arms). + * + * @package mod_questionnaire + * @copyright 2026 Mike Churchward (mike.churchward@poetopensource.org) + * @author Mike Churchward + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class report_viewer { + /** + * Constructor. + * + * @param questionnaire $questionnaire The active questionnaire. + * @param \plugin_renderer_base $renderer Plugin renderer. + */ + public function __construct( + /** @var questionnaire The active questionnaire. */ + private readonly questionnaire $questionnaire, + /** @var \plugin_renderer_base Plugin renderer. */ + private readonly \plugin_renderer_base $renderer, + ) { + } + + /** + * Render the "view all responses" page (vall / vallasort / vallarsort arms). + * + * @param object $page Templatable report page. + * @param string $action One of 'vall', 'vallasort', 'vallarsort'. + * @param string $outputtarget 'html', 'pdf', or 'print'. + * @param \moodle_url $url Base URL for the current page (used by group select and action links). + * @param int $groupmode Activity group mode (0 = none, 1 = separate, 2 = visible). + * @param int $currentgroupid Active group filter id. + * @param array $questionnairegroups Available groups (id => obj). + * @param array $respsallparticipants All-participant responses (pre-fetched). + * @param string $sort 'ascending', 'descending', or 'default'. + * @param string $userview The 'responsestats' filter ('y', '0', or 'n'). + * @param array $responsestatus Map of response-status labels keyed by 'y' / '0' / 'n'. + * @param bool $usergraph Whether to inject RGraph JS for chart rendering. + * @return void + */ + public function view_all_responses( + object $page, + string $action, + string $outputtarget, + \moodle_url $url, + int $groupmode, + int $currentgroupid, + array $questionnairegroups, + array $respsallparticipants, + string $sort, + string $userview, + array $responsestatus, + bool $usergraph + ): void { + global $PAGE; + + $course = $this->questionnaire->course(); + $context = $this->questionnaire->context(); + $cm = $this->questionnaire->coursemodule(); + $instance = $this->questionnaire->id(); + + $PAGE->set_title(get_string('questionnairereport', 'questionnaire')); + $PAGE->set_heading(format_string($course->fullname)); + $canviewallresponses = has_capability('mod/questionnaire:readallresponses', $context); + $canviewallresponsesanytime = has_capability('mod/questionnaire:readallresponseanytime', $context); + if (!$canviewallresponses && !$canviewallresponsesanytime) { + echo $this->renderer->header(); + throw new \moodle_exception('nopermissions', 'mod_questionnaire'); + } + + $this->init_rgraph($usergraph); + + $currenttab = match ($action) { + 'vallasort' => 'vallasort', + 'vallarsort' => 'vallarsort', + default => 'valldefault', + }; + if ($outputtarget != 'print') { + (new \mod_questionnaire\output\tabs($this->questionnaire, $currenttab, $currentgroupid))->render($page); + } + + $respinfo = ''; + $resps = []; + // Enable choose_group if there are questionnaire groups and groupmode is not set to "no groups" + // and if there are more groups than 1 (or if user can view all groups). + if (is_array($questionnairegroups) && $groupmode > 0) { + $groupselect = groups_print_activity_menu($cm, $url->out(), true); + // Count number of responses in each group. + foreach ($questionnairegroups as $group) { + $respscount = $this->questionnaire->count_submissions(false, $group->id); + $thisgroupname = groups_get_group_name($group->id); + $escapedgroupname = preg_quote($thisgroupname, '/'); + if (!empty($respscount)) { + $groupselect = preg_replace( + '/\', + $groupselect + ); + } else { + $groupselect = preg_replace( + '/\