');
- 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 +470,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 +488,48 @@ public function definition() {
$qnumber = '
';
$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/responsetype/rank.php b/classes/responsetype/rank.php
deleted file mode 100644
index b9d9b4162..000000000
--- a/classes/responsetype/rank.php
+++ /dev/null
@@ -1,1103 +0,0 @@
-.
-
-namespace mod_questionnaire\responsetype;
-
-use Composer\Package\Package;
-use mod_questionnaire\db\bulk_sql_config;
-
-/**
- * Class for rank responses.
- *
- * @author Mike Churchward
- * @copyright 2016 onward Mike Churchward (mike.churchward@poetopensource.org)
- * @license http://www.gnu.org/copyleft/gpl.html GNU Public License
- * @package mod_questionnaire
- */
-class rank extends responsetype {
- /**
- * @var \stdClass $counts Range counts.
- */
- public $counts;
-
- /**
- * Provide the necessary response data table name. Should probably always be used with late static binding 'static::' form
- * rather than 'self::' form to allow for class extending.
- *
- * @return string response table name.
- */
- public static function response_table() {
- return 'questionnaire_response_rank';
- }
-
- /**
- * 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.
- * @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;
- // Choice not set or not answered.
- if (!isset($other) || $other == '') {
- continue;
- }
- if ($other == get_string('notapplicable', 'questionnaire')) {
- $rank = -1;
- } else {
- $rank = intval($other);
- }
- $record = new \stdClass();
- $record->responseid = $responsedata->rid;
- $record->questionid = $question->id;
- $record->choiceid = $cid;
- $record->value = $rank;
- $answers[$cid] = answer\answer::create_from_data($record);
- }
- return $answers;
- }
-
- /**
- * 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.
- */
- 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($question->choices[$choiceid])) {
- $record = new \stdClass();
- $record->responseid = $responsedata->rid;
- $record->questionid = $question->id;
- $record->choiceid = $choiceid;
- if (!empty($question->nameddegrees)) {
- // If using named degrees, the app returns the label string. Find the value.
- $nameddegreevalue = array_search($choicevalue, $question->nameddegrees);
- if ($nameddegreevalue !== false) {
- $choicevalue = $nameddegreevalue;
- }
- }
- $record->value = $choicevalue;
- $answers[] = answer\answer::create_from_data($record);
- }
- }
- }
- return $answers;
- }
-
- /**
- * Insert a provided response to the question.
- *
- * @param object $responsedata All of the responsedata as an object.
- * @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]);
- } else {
- $response = $responsedata;
- }
-
- $resid = false;
-
- 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);
- 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);
- }
- }
- }
- return $resid;
- }
-
- /**
- * Provide the result information for the specified result records.
- *
- * @param bool $rids
- * @param bool $anonymous
- * @return array
- *
- * TODO - This works differently than all other get_results methods. This needs to be refactored.
- */
- public function get_results($rids = false, $anonymous = false) {
- global $DB;
-
- $rsql = '';
- $params = [];
- if (!empty($rids)) {
- [$rsql, $params] = $DB->get_in_or_equal($rids);
- $rsql = ' AND response_id ' . $rsql;
- }
- // Get other choices.
- $otherrecs = $this->get_other_choice($rsql, $params);
-
- $select = 'question_id=' . $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,
- 'rankvalue' => '-1',
- ]
- );
- if (\mod_questionnaire\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;
- $this->counts[$key]->content = $row->content;
- }
- } else {
- $this->counts[$row->content] = new \stdClass();
- $this->counts[$row->content]->nbna = $nbna;
- }
- }
- }
-
- // For nameddegrees, need an array by degree value of positions (zero indexed).
- $rankvalue = [];
- if (!empty($this->question->nameddegrees)) {
- $rankvalue = array_flip(array_keys($this->question->nameddegrees));
- }
-
- $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 . "
- AND r.rankvalue >= 0{$rsql}
- ORDER BY choiceid";
- $results = $DB->get_records_sql($sql, $params);
- $value = [];
- foreach ($results as $result) {
- if (isset($rankvalue[$result->rankvalue])) {
- if (isset($value[$result->choiceid])) {
- $value[$result->choiceid] += $rankvalue[$result->rankvalue] + 1;
- } else {
- $value[$result->choiceid] = $rankvalue[$result->rankvalue] + 1;
- }
- }
- }
- }
-
- $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
- FROM {questionnaire_quest_choice} c2, {" . static::response_table() . "} a2
- WHERE c2.question_id = ? AND a2.question_id = ? AND a2.choice_id = 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));
-
- // Handle 'other...'.
- if ($otherrecs) {
- $i = 1;
- foreach ($otherrecs as $rec) {
- $results['other' . $i] = new \stdClass();
- $results['other' . $i]->id = $rec->cid;
- $results['other' . $i]->content = $rec->response;
- $results['other' . $i]->average = $rec->average;
- $results['other' . $i]->num = $rec->num;
- $results['other' . $i]->isother = true;
- $i++;
- }
- }
-
- if (!empty($rankvalue)) {
- foreach ($results as $key => $result) {
- if (isset($value[$key])) {
- $result->averagevalue = $value[$key] / $result->num;
- }
- }
- }
- // Reindex by 'content'. Can't do this from the query as it won't work with MS-SQL.
- foreach ($results as $key => $result) {
- $results[$result->content] = $result;
- unset($results[$key]);
- }
- return $results;
- // Case where scaleitems is less than possible choices.
- } else {
- $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
- FROM {questionnaire_quest_choice} c2, {" . static::response_table() . "} a2
- WHERE c2.question_id = ? AND a2.question_id = ? AND a2.choice_id = 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));
-
- if ($otherrecs) {
- $i = 1;
- foreach ($otherrecs as $rec) {
- $results['other' . $i] = new \stdClass();
- $results['other' . $i]->id = $rec->cid;
- $results['other' . $i]->content = $rec->response;
- $results['other' . $i]->sum = $rec->average;
- $results['other' . $i]->num = $rec->num;
- $results['other' . $i]->isother = true;
- $i++;
- }
- }
- // 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;
- } else {
- $result->average = ($result->sum + ($nbresponses - $result->num) * 1 ) / $nbresponses;
- }
- $results[$result->content] = $result;
- unset($results[$key]);
- }
- return $results;
- }
- }
-
- /**
- * Get a list of other available choices.
- *
- * @param string $rsql
- * @param array $params
- * @return array
- */
- 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
- FROM {questionnaire_quest_choice} c
- INNER JOIN {" . static::response_table() . "} a ON a.choice_id = 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 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]));
- }
-
- /**
- * Provide the feedback scores for all requested response id's. This should be provided only by questions that provide feedback.
- * @param array $rids
- * @return array | boolean
- */
- public function get_feedback_scores(array $rids) {
- global $DB;
-
- $rsql = '';
- $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;
- }
- $params[] = 'y';
-
- $sql = 'SELECT r.id, r.response_id as rid, r.question_id AS qid, r.choice_id 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 . ' ' .
- 'ORDER BY rid,cid ASC';
- $responses = $DB->get_recordset_sql($sql, $params);
-
- $rid = 0;
- $feedbackscores = [];
- foreach ($responses as $response) {
- if ($rid != $response->rid) {
- $rid = $response->rid;
- $feedbackscores[$rid] = new \stdClass();
- $feedbackscores[$rid]->rid = $rid;
- $feedbackscores[$rid]->score = 0;
- }
- // Only count scores that are currently defined (in case old responses are using older data).
- $feedbackscores[$rid]->score += isset($this->question->nameddegrees[$response->rankvalue]) ? $response->rankvalue : 0;
- }
-
- return (!empty($feedbackscores) ? $feedbackscores : false);
- }
-
- /**
- * Provide a template for results screen if defined.
- * @param bool $pdf
- * @return mixed The template string or false/
- */
- public function results_template($pdf = false) {
- if ($pdf) {
- return 'mod_questionnaire/resultspdf_rate';
- } else {
- return 'mod_questionnaire/results_rate';
- }
- }
-
- /**
- * Provide the result information for the specified result records.
- *
- * @param int|array $rids - A single response id, or array.
- * @param string $sort - Optional display sort.
- * @param boolean $anonymous - Whether or not responses are anonymous.
- * @return string - Display output.
- */
- public function display_results($rids = false, $sort = '', $anonymous = false) {
- $output = '';
-
- if (is_array($rids)) {
- $prtotal = 1;
- } else if (is_int($rids)) {
- $prtotal = 0;
- }
-
- if ($rows = $this->get_results($rids, $sort, $anonymous)) {
- $stravgvalue = ''; // For printing table heading.
- foreach ($this->counts as $key => $value) {
- $ccontent = $key;
- $avgvalue = '';
- if (array_key_exists($ccontent, $rows)) {
- $avg = $rows[$ccontent]->average;
- $this->counts[$ccontent]->num = $rows[$ccontent]->num;
- if (isset($rows[$ccontent]->averagevalue)) {
- $avgvalue = $rows[$ccontent]->averagevalue;
- $osgood = false;
- if ($this->question->osgood_rate_scale()) { // Osgood's semantic differential.
- $osgood = true;
- }
- if ($stravgvalue == '' && !$osgood) {
- $stravgvalue = ' (' . get_string('andaveragevalues', 'questionnaire') . ')';
- }
- } else {
- $avgvalue = null;
- }
- } else {
- $avg = 0;
- }
- $this->counts[$ccontent]->avg = $avg;
- $this->counts[$ccontent]->avgvalue = $avgvalue;
- }
- $output1 = $this->mkresavg($sort, $stravgvalue);
- $output2 = $this->mkrescount($rids, $rows, $sort);
- $output = (object)array_merge((array)$output1, (array)$output2);
- } else {
- $output = (object)['noresponses' => true];
- }
- return $output;
- }
-
- /**
- * Return an array of answers by question/choice for the given response. Must be implemented by the subclass.
- *
- * @param int $rid The response id.
- * @return array
- */
- public static function response_select($rid) {
- global $DB;
-
- $values = [];
- $sql = 'SELECT 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';
- $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)) {
- $osgood = true;
- }
- $qid = $row->qid . '_' . $row->cid;
- unset($row->aid); // Get rid of the answer id.
- unset($row->qid);
- unset($row->cid);
- unset($row->precise);
- $row = (array)$row;
- $newrow = [];
- foreach ($row as $key => $val) {
- if ($key != 'content') { // No need to keep question text - ony keep choice text and rank.
- if ($key == 'ccontent') {
- if ($osgood) {
- [$contentleft, $contentright] = array_merge(preg_split('/[|]/', $val), [' ']);
- $contents = questionnaire_choice_values($contentleft);
- if ($contents->title) {
- $contentleft = $contents->title;
- }
- $contents = questionnaire_choice_values($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);
- if ($contents->modname) {
- $val = $contents->modname;
- } else if ($contents->title) {
- $val = $contents->title;
- } else if ($contents->text) {
- $val = strip_tags($contents->text);
- $val = preg_replace("/[\r\n\t]/", ' ', $val);
- }
- }
- }
- $newrow[] = $val;
- }
- }
- $values[$qid] = $newrow;
- }
-
- return $values;
- }
-
- /**
- * Return an array of answer objects by question for the given response id.
- * THIS SHOULD REPLACE response_select.
- *
- * @param int $rid The response id.
- * @return array array answer
- * @throws \dml_exception
- */
- public static function response_answers_by_question($rid) {
- global $DB;
-
- $answers = [];
- $sql = 'SELECT r.id,
- r.response_id AS responseid,
- r.question_id AS questionid,
- r.choice_id 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 = ?';
- $records = $DB->get_records_sql($sql, [$rid]);
- foreach ($records as $record) {
- $answers[$record->questionid][$record->choiceid] = answer\answer::create_from_data($record);
- }
-
- return $answers;
- }
-
- /**
- * Configure bulk sql
- * @return bulk_sql_config
- */
- protected function bulk_sql_config() {
- return new bulk_sql_config(static::response_table(), 'qrr', true, false, true);
- }
-
- /**
- * Return a structure for averages.
- * @param string $sort
- * @param string $stravgvalue
- * @return \stdClass
- */
- private function mkresavg($sort, $stravgvalue = '') {
- global $CFG;
-
- $stravgrank = get_string('averagerank', 'questionnaire');
- $osgood = false;
- if ($this->question->precise == 3) { // Osgood's semantic differential.
- $osgood = true;
- $stravgrank = get_string('averageposition', 'questionnaire');
- }
- $stravg = '
' . $stravgrank . $stravgvalue . '
';
-
- $isna = $this->question->precise == 1;
- $isnahead = '';
- $nbchoices = count($this->counts);
- $isrestricted = ($this->question->length < $nbchoices) && $this->question->precise == 2;
-
- if ($isna) {
- $isnahead = get_string('notapplicable', 'questionnaire');
- }
- $pagetags = new \stdClass();
- $pagetags->averages = new \stdClass();
-
- if ($isna) {
- $header1 = new \stdClass();
- $header1->text = '';
- $header1->align = '';
- $header2 = new \stdClass();
- $header2->text = $stravg;
- $header2->align = '';
- $header3 = new \stdClass();
- $header3->text = '⇓';
- $header3->align = 'center';
- $header4 = new \stdClass();
- $header4->text = $isnahead;
- $header4->align = 'right';
- } else {
- if ($osgood) {
- $stravg = '
' . $stravgrank . '
';
- $header1 = new \stdClass();
- $header1->text = '';
- $header1->align = '';
- $header2 = new \stdClass();
- $header2->text = $stravg;
- $header2->align = '';
- $header3 = new \stdClass();
- $header3->text = '';
- $header3->align = 'center';
- } else {
- $header1 = new \stdClass();
- $header1->text = '';
- $header1->align = '';
- $header2 = new \stdClass();
- $header2->text = $stravg;
- $header2->align = '';
- $header3 = new \stdClass();
- $header3->text = '⇓';
- $header3->align = 'center';
- }
- }
- // PDF columns are based on a 11.69in x 8.27in page. Margins are 15mm each side, or 1.1811 in total.
- $pdfwidth = 11.69 - 1.1811;
- if ($isna) {
- $header1->width = '55%';
- $header2->width = '35%';
- $header3->width = '5%';
- $header4->width = '5%';
- $header1->pdfwidth = $pdfwidth * .55;
- $header2->pdfwidth = $pdfwidth * .35;
- $header3->pdfwidth = $pdfwidth * .05;
- $header4->pdfwidth = $pdfwidth * .05;
- } else if ($osgood) {
- $header1->width = '25%';
- $header2->width = '50%';
- $header3->width = '25%';
- $header1->pdfwidth = $pdfwidth * .25;
- $header2->pdfwidth = $pdfwidth * .5;
- $header3->pdfwidth = $pdfwidth * .25;
- } else {
- $header1->width = '60%';
- $header2->width = '35%';
- $header3->width = '5%';
- $header1->pdfwidth = $pdfwidth * .6;
- $header2->pdfwidth = $pdfwidth * .35;
- $header3->pdfwidth = $pdfwidth * .05;
- }
- $pagetags->averages->headers = [$header1, $header2, $header3];
- if (isset($header4)) {
- $pagetags->averages->headers[] = $header4;
- }
-
- $imageurl = $CFG->wwwroot . '/mod/questionnaire/images/hbar.gif';
- $spacerimage = $CFG->wwwroot . '/mod/questionnaire/images/hbartransp.gif';
- $llength = $this->question->length;
- if (!$llength) {
- $llength = 5;
- }
- // Add an extra column to accomodate lower ranks in this case.
- $llength += $isrestricted;
- $width = 100 / $llength;
- $n = [];
- $nameddegrees = 0;
- foreach ($this->question->nameddegrees as $degree) {
- // To take into account languages filter.
- $content = (format_text($degree, FORMAT_HTML, ['noclean' => true]));
- $n[$nameddegrees] = $degree;
- $nameddegrees++;
- }
- for ($j = 0; $j < $this->question->length; $j++) {
- if (isset($n[$j])) {
- $str = $n[$j];
- } else {
- $str = $j + 1;
- }
- }
- $rankcols = [];
- $pdfwidth = $header2->pdfwidth / (100 / $width);
- for ($i = 0; $i <= $llength - 1; $i++) {
- if ($isrestricted && $i == $llength - 1) {
- $str = "...";
- $rankcols[] = (object)['width' => $width . '%', 'text' => '...', 'pdfwidth' => $pdfwidth];
- } else if (isset($n[$i])) {
- $str = $n[$i];
- $rankcols[] = (object)['width' => $width . '%', 'text' => $n[$i], 'pdfwidth' => $pdfwidth];
- } else {
- $str = $i + 1;
- $rankcols[] = (object)['width' => $width . '%', 'text' => $i + 1, 'pdfwidth' => $pdfwidth];
- }
- }
- $pagetags->averages->choicelabelrow = new \stdClass();
- $pagetags->averages->choicelabelrow->innertablewidth = $header2->pdfwidth;
- $pagetags->averages->choicelabelrow->column1 = (object)['width' => $header1->width, 'align' => $header1->align,
- 'text' => '', 'pdfwidth' => $header1->pdfwidth];
- $pagetags->averages->choicelabelrow->column2 = (object)['width' => $header2->width, 'align' => $header2->align,
- 'ranks' => $rankcols, 'pdfwidth' => $header2->pdfwidth];
- $pagetags->averages->choicelabelrow->column3 = (object)['width' => $header3->width, 'align' => $header3->align,
- 'text' => '', 'pdfwidth' => $header3->pdfwidth];
- if ($isna) {
- $pagetags->averages->choicelabelrow->column4 = (object)['width' => $header4->width, 'align' => $header4->align,
- 'text' => '', 'pdfwidth' => $header4->pdfwidth];
- }
-
- switch ($sort) {
- case 'ascending':
- uasort($this->counts, self::class . '::sortavgasc');
- break;
- case 'descending':
- uasort($this->counts, self::class . '::sortavgdesc');
- break;
- }
- reset($this->counts);
-
- if (!empty($this->counts) && is_array($this->counts)) {
- $pagetags->averages->choiceaverages = [];
- foreach ($this->counts as $content => $contentobj) {
- // Eliminate potential named degrees on Likert scale.
- if (!preg_match("/^[0-9]{1,3}=/", $content)) {
- if (isset($contentobj->avg)) {
- $avg = $contentobj->avg;
- // If named degrees were used, swap averages for display.
- if (isset($contentobj->avgvalue)) {
- $avg = $contentobj->avgvalue;
- $avgvalue = $contentobj->avg;
- } else {
- $avgvalue = '';
- }
- } else {
- $avg = '';
- }
- $nbna = $contentobj->nbna;
-
- if ($avg) {
- if (($j = $avg * $width) > 0) {
- $marginposition = ($avg - 0.5 ) / ($this->question->length + $isrestricted);
- }
- if (!right_to_left()) {
- $margin = 'margin-left:' . $marginposition * 100 . '%';
- $marginpdf = $marginposition * $pagetags->averages->choicelabelrow->innertablewidth;
- } else {
- $margin = 'margin-right:' . $marginposition * 100 . '%';
- $marginpdf = $pagetags->averages->choicelabelrow->innertablewidth -
- ($marginposition * $pagetags->averages->choicelabelrow->innertablewidth);
- }
- } else {
- $margin = '';
- }
-
- if ($osgood) {
- // Ensure there are two bits of content.
- [$content, $contentright] = array_merge(preg_split('/[|]/', $content), [' ']);
- } else {
- $contents = questionnaire_choice_values($content);
- if ($contents->modname) {
- $content = $contents->text;
- }
- }
- if (
- isset($contentobj->content) &&
- \mod_questionnaire\question\choice::content_other_choice_display($contentobj->content)
- ) {
- $othertext = \mod_questionnaire\question\choice::content_other_choice_display($contentobj->content);
- $content = $othertext . ' ' . clean_text($content);
- }
- if ($osgood) {
- $choicecol1 = new \stdClass();
- $choicecol1->width = $header1->width;
- $choicecol1->pdfwidth = $header1->pdfwidth;
- $choicecol1->align = $header1->align;
- $choicecol1->text = '
' .
- format_text($content, FORMAT_HTML, ['noclean' => true]) . '
';
- $choicecol2 = new \stdClass();
- $choicecol2->width = $header2->width;
- $choicecol2->pdfwidth = $header2->pdfwidth;
- $choicecol2->align = $header2->align;
- $choicecol2->imageurl = $imageurl;
- $choicecol2->spacerimage = $spacerimage;
- $choicecol2->margin = $margin;
- $choicecol2->marginpdf = $marginpdf;
- $choicecol3 = new \stdClass();
- $choicecol3->width = $header3->width;
- $choicecol3->pdfwidth = $header3->pdfwidth;
- $choicecol3->align = $header3->align;
- $choicecol3->text = '
' .
- format_text($contentright, FORMAT_HTML, ['noclean' => true]) . '
';
- $pagetags->averages->choiceaverages[] = (object)['column1' => $choicecol1, 'column2' => $choicecol2,
- 'column3' => $choicecol3];
- // JR JUNE 2012 do not display meaningless average rank values for Osgood.
- } else if ($avg || ($nbna != 0)) {
- $stravgval = '';
- if ($avg) {
- if ($stravgvalue) {
- $stravgval = '(' . sprintf('%.1f', $avgvalue) . ')';
- }
- $stravgval = sprintf('%.1f', $avg) . ' ' . $stravgval;
- if ($isna) {
- $choicecol4 = new \stdClass();
- $choicecol4->width = $header4->width;
- $choicecol4->pdfwidth = $header4->pdfwidth;
- $choicecol4->align = $header4->align;
- $choicecol4->text = $nbna;
- }
- }
- $choicecol1 = new \stdClass();
- $choicecol1->width = $header1->width;
- $choicecol1->pdfwidth = $header1->pdfwidth;
- $choicecol1->align = $header1->align;
- $choicecol1->text = format_text($content, FORMAT_HTML, ['noclean' => true]);
- $choicecol2 = new \stdClass();
- $choicecol2->width = $header2->width;
- $choicecol2->pdfwidth = $header2->pdfwidth;
- $choicecol2->align = $header2->align;
- $choicecol2->imageurl = $imageurl;
- $choicecol2->spacerimage = $spacerimage;
- $choicecol2->margin = $margin;
- $choicecol2->marginpdf = $marginpdf;
- $choicecol3 = new \stdClass();
- $choicecol3->width = $header3->width;
- $choicecol3->pdfwidth = $header3->pdfwidth;
- $choicecol3->align = $header3->align;
- $choicecol3->text = $stravgval;
- if ($avg) {
- if (isset($choicecol4)) {
- $pagetags->averages->choiceaverages[] = (object)['column1' => $choicecol1,
- 'column2' => $choicecol2, 'column3' => $choicecol3, 'column4' => $choicecol4];
- } else {
- $pagetags->averages->choiceaverages[] = (object)['column1' => $choicecol1,
- 'column2' => $choicecol2, 'column3' => $choicecol3];
- }
- } else {
- $choicecol4 = new \stdClass();
- $choicecol4->width = $header4->width;
- $choicecol4->pdfwidth = $header4->pdfwidth;
- $choicecol4->align = $header4->align;
- $choicecol4->text = $nbna;
- $pagetags->averages->choiceaverages[] = (object)['column1' => $choicecol1, 'column2' => $choicecol2,
- 'column3' => $choicecol3];
- }
- }
- } // End if named degrees.
- } // End foreach.
- } else {
- $nodata1 = new \stdClass();
- $nodata1->width = $header1->width;
- $nodata1->align = $header1->align;
- $nodata1->text = '';
- $nodata2 = new \stdClass();
- $nodata2->width = $header2->width;
- $nodata2->align = $header2->align;
- $nodata2->text = get_string('noresponsedata', 'mod_questionnaire');
- $nodata3 = new \stdClass();
- $nodata3->width = $header3->width;
- $nodata3->align = $header3->align;
- $nodata3->text = '';
- if (isset($header4)) {
- $nodata4 = new \stdClass();
- $nodata4->width = $header4->width;
- $nodata4->align = $header4->align;
- $nodata4->text = '';
- $pagetags->averages->nodata = [$nodata1, $nodata2, $nodata3, $nodata4];
- } else {
- $pagetags->averages->nodata = [$nodata1, $nodata2, $nodata3];
- }
- }
- return $pagetags;
- }
-
- /**
- * Return a structure for counts.
- * @param array $rids
- * @param array $rows
- * @param string $sort
- * @return \stdClass
- */
- private function mkrescount($rids, $rows, $sort) {
- // Display number of responses to Rate questions - see http://moodle.org/mod/forum/discuss.php?d=185106.
- global $DB;
-
- $nbresponses = count($rids);
- // Prepare data to be displayed.
- $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;
- }
-
- // This is question_id.
- 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 <> ''))
- ORDER BY choiceid, rankvalue ASC";
- $choices = $DB->get_records_sql($sql, $params);
-
- // Sort rows (results) by average value.
- if ($sort != 'default') {
- $sortarray = [];
- foreach ($rows as $row) {
- foreach ($row as $key => $value) {
- if (!isset($sortarray[$key])) {
- $sortarray[$key] = [];
- }
- $sortarray[$key][] = $value;
- }
- }
- $orderby = "average";
- switch ($sort) {
- case 'ascending':
- array_multisort($sortarray[$orderby], SORT_ASC, $rows);
- break;
- case 'descending':
- array_multisort($sortarray[$orderby], SORT_DESC, $rows);
- break;
- }
- }
- $nbranks = $this->question->length;
- $ranks = [];
- $rankvalue = [];
- if (!empty($this->question->nameddegrees)) {
- $rankvalue = array_flip(array_keys($this->question->nameddegrees));
- }
- foreach ($rows as $key => $row) {
- $choiceid = $row->id;
- foreach ($choices as $choice) {
- if ($choice->choiceid == $choiceid && $choice->content === $key) {
- $n = 0;
- for ($i = 1; $i <= $nbranks; $i++) {
- if (
- (isset($rankvalue[$choice->rankvalue]) && ($rankvalue[$choice->rankvalue] == ($i - 1))) ||
- (empty($rankvalue) && ($choice->rankvalue == $i))
- ) {
- $n++;
- if (!isset($ranks[$choice->content][$i])) {
- $ranks[$choice->content][$i] = 0;
- }
- $ranks[$choice->content][$i] += $n;
- } else if (!isset($ranks[$choice->content][$i])) {
- $ranks[$choice->content][$i] = 0;
- }
- }
- }
- }
- }
-
- // Psettings for display.
- $strtotal = '
' . get_string('total', 'questionnaire') . ' ';
- $isna = $this->question->precise == 1;
- $osgood = false;
- if ($this->question->precise == 3) { // Osgood's semantic differential.
- $osgood = true;
- }
- if ($this->question->precise == 1) {
- $na = get_string('notapplicable', 'questionnaire');
- } else {
- $na = '';
- }
- $nameddegrees = 0;
- $n = [];
- foreach ($this->question->nameddegrees as $degree) {
- $content = $degree;
- $n[$nameddegrees] = format_text($content, FORMAT_HTML, ['noclean' => true]);
- $nameddegrees++;
- }
- foreach ($this->question->choices as $choice) {
- $contents = questionnaire_choice_values($choice->content);
- if ($contents->modname) {
- $choice->content = $contents->text;
- }
- }
-
- $pagetags = new \stdClass();
- $pagetags->totals = new \stdClass();
- $pagetags->totals->headers = [];
- if ($osgood) {
- $align = 'right';
- } else {
- $align = 'left';
- }
- $pagetags->totals->headers[] = (object)[
- 'align' => $align,
- 'text' => '
' . get_string('responses', 'questionnaire') . ' ',
- ];
-
- // Display the column titles.
- for ($j = 0; $j < $this->question->length; $j++) {
- if (isset($n[$j])) {
- $str = $n[$j];
- } else {
- $str = $j + 1;
- }
- $pagetags->totals->headers[] = (object)['align' => 'center', 'text' => '
' . $str . ' '];
- }
- if ($osgood) {
- $pagetags->totals->headers[] = (object)['align' => 'left', 'text' => ''];
- }
- $pagetags->totals->headers[] = (object)['align' => 'center', 'text' => $strtotal];
- if ($isrestricted) {
- $pagetags->totals->headers[] = (object)['align' => 'center', 'text' => get_string('notapplicable', 'questionnaire')];
- }
- if ($na) {
- $pagetags->totals->headers[] = (object)['align' => 'center', 'text' => $na];
- }
-
- // Now display the responses.
- $pagetags->totals->choices = [];
- foreach ($ranks as $content => $rank) {
- $totalcols = [];
- // Eliminate potential named degrees on Likert scale.
- if (!preg_match("/^[0-9]{1,3}=/", $content)) {
- // First display the list of degrees (named or un-named)
- // number of NOT AVAILABLE responses for this possible answer.
- $nbna = $this->counts[$content]->nbna;
- // TOTAL number of responses for this possible answer.
- $total = $this->counts[$content]->num;
- $nbresp = '
(' . $total . ') ';
- if ($osgood) {
- // Ensure there are two bits of content.
- [$content, $contentright] = array_merge(preg_split('/[|]/', $content), [' ']);
- $header = reset($pagetags->totals->headers);
- if (isset($rows[$content]) && isset($rows[$content]->isother) && $rows[$content]->isother) {
- $content = get_string('other', 'questionnaire') . ' ' . $content;
- }
- $totalcols[] = (object)['align' => $header->align,
- 'text' => format_text($content, FORMAT_HTML, ['noclean' => true, 'filter' => false])];
- } else {
- // Eliminate potentially short-named choices.
- $contents = questionnaire_choice_values($content);
- if ($contents->modname) {
- $content = $contents->text;
- }
- if (isset($rows[$content]) && isset($rows[$content]->isother) && $rows[$content]->isother) {
- $content = get_string('other', 'questionnaire') . ' ' . $content;
- }
- $header = reset($pagetags->totals->headers);
- $totalcols[] = (object)['align' => $header->align,
- 'text' => format_text($content, FORMAT_HTML, ['noclean' => true, 'filter' => false])];
- }
- // Display ranks/rates numbers.
- $maxrank = max($rank);
- for ($i = 1; $i <= $this->question->length; $i++) {
- $percent = '';
- if (isset($rank[$i])) {
- $str = $rank[$i];
- if ($total !== 0 && $str !== 0) {
- $percent = ' (
' . number_format(($str * 100) / $total) . '% )';
- }
- // Emphasize responses with max rank value.
- if ($str == $maxrank) {
- $str = '
' . $str . ' ';
- }
- } else {
- $str = 0;
- }
- $header = next($pagetags->totals->headers);
- $totalcols[] = (object)['align' => $header->align, 'text' => $str . $percent];
- }
- if ($osgood) {
- $header = next($pagetags->totals->headers);
- $totalcols[] = (object)['align' => $header->align,
- 'text' => format_text($contentright, FORMAT_HTML, ['noclean' => true])];
- }
- $header = next($pagetags->totals->headers);
- $totalcols[] = (object)['align' => $header->align, 'text' => $nbresp];
- if ($isrestricted) {
- $header = next($pagetags->totals->headers);
- $totalcols[] = (object)['align' => $header->align, 'text' => $nbresponses - $total];
- }
- if (!$osgood) {
- if ($na) {
- $header = next($pagetags->totals->headers);
- $totalcols[] = (object)['align' => $header->align, 'text' => $nbna];
- }
- }
- } // End named degrees.
- $pagetags->totals->choices[] = (object)['totalcols' => $totalcols];
- }
- return $pagetags;
- }
-
- /**
- * Sorting function for ascending.
- * @param \stdClass $a
- * @param \stdClass $b
- * @return int
- */
- private static function sortavgasc($a, $b) {
- if (isset($a->avg) && isset($b->avg)) {
- if ($a->avg < $b->avg) {
- return -1;
- } else if ($a->avg > $b->avg) {
- return 1;
- } else {
- return 0;
- }
- }
- }
-
- /**
- * Sorting function for descending.
- * @param \stdClass $a
- * @param \stdClass $b
- * @return int
- */
- private static function sortavgdesc($a, $b) {
- if (isset($a->avg) && isset($b->avg)) {
- if ($a->avg > $b->avg) {
- return -1;
- } else if ($a->avg < $b->avg) {
- return 1;
- } else {
- return 0;
- }
- }
- }
-}
diff --git a/classes/responsetype/response/response.php b/classes/responsetype/response/response.php
deleted file mode 100644
index b24378a2b..000000000
--- a/classes/responsetype/response/response.php
+++ /dev/null
@@ -1,186 +0,0 @@
-.
-
-namespace mod_questionnaire\responsetype\response;
-
-/**
- * This defines a structured class to hold responses.
- *
- * @author Mike Churchward
- * @license http://www.gnu.org/copyleft/gpl.html GNU Public License
- * @package mod_questionnaire
- * @copyright 2019, onwards Poet
- */
-class response {
- // Class properties.
-
- /** @var int $id The id of the response this applies to. */
- public $id;
-
- /** @var int $questionnaireid The id of the questionnaire this response applies to. */
- public $questionnaireid;
-
- /** @var int $userid The id of the user for this response. */
- public $userid;
-
- /** @var int $submitted The most recent submission date of this response. */
- public $submitted;
-
- /** @var bool $complete Flag for final submission of this response. */
- public $complete;
-
- /** @var int $grade Numeric grade for this response (if applicable). */
- public $grade;
-
- /** @var array $answers Array by question of array of answer objects. */
- public $answers;
-
- /**
- * Choice constructor.
- * @param null $id
- * @param null $questionnaireid
- * @param null $userid
- * @param null $submitted
- * @param null $complete
- * @param null $grade
- * @param bool $addanswers
- */
- public function __construct(
- $id = null,
- $questionnaireid = null,
- $userid = null,
- $submitted = null,
- $complete = null,
- $grade = null,
- $addanswers = true
- ) {
- $this->id = $id;
- $this->questionnaireid = $questionnaireid;
- $this->userid = $userid;
- $this->submitted = $submitted;
- $this->complete = $complete;
- $this->grade = $grade;
-
- // Add answers by questions that exist.
- if ($addanswers) {
- $this->add_questions_answers();
- }
- }
-
- /**
- * Create and return a response object from data.
- *
- * @param \stdClass|array $responsedata The data to load.
- * @return response
- */
- public static function create_from_data($responsedata) {
- if (!is_array($responsedata)) {
- $responsedata = (array)$responsedata;
- }
-
- $properties = array_keys(get_class_vars(__CLASS__));
- foreach ($properties as $property) {
- if (!isset($responsedata[$property])) {
- $responsedata[$property] = null;
- }
- }
-
- return new response(
- $responsedata['id'],
- $responsedata['questionnaireid'],
- $responsedata['userid'],
- $responsedata['submitted'],
- $responsedata['complete'],
- $responsedata['grade']
- );
- }
-
- /**
- * Provide a response object from web form data to the question.
- *
- * @param \stdClass $responsedata All of the responsedata as an object.
- * @param array $questions
- * @return response A response object.
- */
- public static function response_from_webform($responsedata, $questions) {
- global $USER;
-
- $questionnaireid = isset($responsedata->questionnaire_id) ? $responsedata->questionnaire_id :
- (isset($responsedata->a) ? $responsedata->a : 0);
- $response = new response($responsedata->rid, $questionnaireid, $USER->id, null, null, null, false);
- foreach ($questions as $question) {
- if ($question->supports_responses()) {
- $response->answers[$question->id] = $question->responsetype::answers_from_webform($responsedata, $question);
- }
- }
- return $response;
- }
-
- /**
- * Provide a response object from mobile app data to the question.
- *
- * @param id $questionnaireid
- * @param id $responseid
- * @param \stdClass $responsedata All of the responsedata as an object.
- * @param array $questions Array of question objects.
- * @return bool|response A response object.
- */
- public static function response_from_appdata($questionnaireid, $responseid, $responsedata, $questions) {
- global $USER;
-
- $response = new response($responseid, $questionnaireid, $USER->id, null, null, null, false);
-
- // Process app data by question and choice and create a webform 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} = [];
- }
- if (isset($parts[3])) {
- $cid = $parts[3];
- } else {
- $cid = 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;
- }
-
- /**
- * Add the answers to each question in a question array of answers structure.
- */
- public function add_questions_answers() {
- $this->answers = [];
- $this->answers += \mod_questionnaire\responsetype\multiple::response_answers_by_question($this->id);
- $this->answers += \mod_questionnaire\responsetype\single::response_answers_by_question($this->id);
- $this->answers += \mod_questionnaire\responsetype\rank::response_answers_by_question($this->id);
- $this->answers += \mod_questionnaire\responsetype\boolean::response_answers_by_question($this->id);
- $this->answers += \mod_questionnaire\responsetype\date::response_answers_by_question($this->id);
- $this->answers += \mod_questionnaire\responsetype\text::response_answers_by_question($this->id);
- $this->answers += \mod_questionnaire\responsetype\file::response_answers_by_question($this->id);
- }
-}
diff --git a/classes/settings_form.php b/classes/settings_form.php
index c702f6a9b..92aed7c02 100644
--- a/classes/settings_form.php
+++ b/classes/settings_form.php
@@ -32,22 +32,22 @@ class settings_form extends \moodleform {
* Defines the form.
*/
public function definition() {
- global $questionnaire, $questionnairerealms;
+ global $questionnaire;
$mform =& $this->_form;
$mform->addElement('header', 'contenthdr', get_string('contentoptions', 'questionnaire'));
- $capabilities = questionnaire_load_capabilities($questionnaire->cm->id);
- if (!$capabilities->createtemplates) {
- unset($questionnairerealms['template']);
+ $realms = \mod_questionnaire\form_options::realm();
+ if (!$questionnaire->capabilities()->can_create_templates()) {
+ unset($realms['template']);
}
- if (!$capabilities->createpublic) {
- unset($questionnairerealms['public']);
+ if (!$questionnaire->capabilities()->can_create_public()) {
+ unset($realms['public']);
}
- if (isset($questionnairerealms['public']) || isset($questionnairerealms['template'])) {
- $mform->addElement('select', 'realm', get_string('realm', 'questionnaire'), $questionnairerealms);
- $mform->setDefault('realm', $questionnaire->survey->realm);
+ if (isset($realms['public']) || isset($realms['template'])) {
+ $mform->addElement('select', 'realm', get_string('realm', 'questionnaire'), $realms);
+ $mform->setDefault('realm', $questionnaire->survey()->realm());
$mform->addHelpButton('realm', 'realm', 'questionnaire');
} else {
$mform->addElement('hidden', 'realm', 'private');
@@ -55,40 +55,40 @@ public function definition() {
$mform->setType('realm', PARAM_RAW);
$mform->addElement('text', 'title', get_string('title', 'questionnaire'), ['size' => '60']);
- $mform->setDefault('title', $questionnaire->survey->title);
+ $mform->setDefault('title', $questionnaire->surveytitle());
$mform->setType('title', PARAM_TEXT);
$mform->addRule('title', null, 'required', null, 'client');
$mform->addHelpButton('title', 'title', 'questionnaire');
$mform->addElement('text', 'subtitle', get_string('subtitle', 'questionnaire'), ['size' => '60']);
- $mform->setDefault('subtitle', $questionnaire->survey->subtitle);
+ $mform->setDefault('subtitle', $questionnaire->surveysubtitle());
$mform->setType('subtitle', PARAM_TEXT);
$mform->addHelpButton('subtitle', 'subtitle', 'questionnaire');
$editoroptions = ['maxfiles' => EDITOR_UNLIMITED_FILES, 'trusttext' => true];
$mform->addElement('editor', 'info', get_string('additionalinfo', 'questionnaire'), null, $editoroptions);
- $mform->setDefault('info', $questionnaire->survey->info);
+ $mform->setDefault('info', $questionnaire->surveyinfo());
$mform->setType('info', PARAM_RAW);
$mform->addHelpButton('info', 'additionalinfo', 'questionnaire');
$mform->addElement('header', 'submithdr', get_string('submitoptions', 'questionnaire'));
- $mform->addElement('text', 'thanks_page', get_string('url', 'questionnaire'), ['size' => '60']);
- $mform->setType('thanks_page', PARAM_TEXT);
- $mform->setDefault('thanks_page', $questionnaire->survey->thanks_page);
- $mform->addHelpButton('thanks_page', 'url', 'questionnaire');
+ $mform->addElement('text', 'thankspage', get_string('url', 'questionnaire'), ['size' => '60']);
+ $mform->setType('thankspage', PARAM_TEXT);
+ $mform->setDefault('thankspage', $questionnaire->survey()->thankspage());
+ $mform->addHelpButton('thankspage', 'url', 'questionnaire');
$mform->addElement('static', 'confmes', get_string('confalts', 'questionnaire'));
$mform->addHelpButton('confmes', 'confpage', 'questionnaire');
- $mform->addElement('text', 'thank_head', get_string('headingtext', 'questionnaire'), ['size' => '30']);
- $mform->setType('thank_head', PARAM_TEXT);
- $mform->setDefault('thank_head', $questionnaire->survey->thank_head);
+ $mform->addElement('text', 'thankhead', get_string('headingtext', 'questionnaire'), ['size' => '30']);
+ $mform->setType('thankhead', PARAM_TEXT);
+ $mform->setDefault('thankhead', $questionnaire->survey()->thankhead());
$editoroptions = ['maxfiles' => EDITOR_UNLIMITED_FILES, 'trusttext' => true];
- $mform->addElement('editor', 'thank_body', get_string('bodytext', 'questionnaire'), null, $editoroptions);
- $mform->setType('thank_body', PARAM_RAW);
- $mform->setDefault('thank_body', $questionnaire->survey->thank_body);
+ $mform->addElement('editor', 'thankbody', get_string('bodytext', 'questionnaire'), null, $editoroptions);
+ $mform->setType('thankbody', PARAM_RAW);
+ $mform->setDefault('thankbody', $questionnaire->survey()->thankbody());
$allowemailreporting = get_config('questionnaire', 'allowemailreporting');
if (!$allowemailreporting) {
@@ -98,7 +98,7 @@ public function definition() {
}
$mform->addElement('text', 'email', get_string('email', 'questionnaire'), $attributes);
$mform->setType('email', PARAM_TEXT);
- $mform->setDefault('email', $questionnaire->survey->email);
+ $mform->setDefault('email', $questionnaire->survey()->email());
$mform->addHelpButton('email', 'sendemail', 'questionnaire');
// Hidden fields.
diff --git a/classes/submission_controller.php b/classes/submission_controller.php
new file mode 100644
index 000000000..d3e14d1a2
--- /dev/null
+++ b/classes/submission_controller.php
@@ -0,0 +1,90 @@
+.
+
+namespace mod_questionnaire;
+
+/**
+ * Controller for in-progress submission flow: page navigation and section saves.
+ *
+ * Coordinates the responses handler and question navigator when a user is
+ * working through a multi-page questionnaire: replacing the answers for the
+ * current section, then deciding which page to go to next.
+ *
+ * @package mod_questionnaire
+ * @copyright 2026 onward Mike Churchward (mike.churchward@poetopensource.org)
+ * @author Mike Churchward
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class submission_controller {
+ /** @var questionnaire The questionnaire instance this controller operates on. */
+ private readonly questionnaire $questionnaire;
+
+ /**
+ * Constructor.
+ *
+ * @param questionnaire $questionnaire
+ */
+ public function __construct(questionnaire $questionnaire) {
+ $this->questionnaire = $questionnaire;
+ }
+
+ /**
+ * Delete the current response section and insert a fresh one; return the new response id.
+ *
+ * @param object $response Response data object (must have ->rid and ->sec).
+ * @param int $userid
+ * @return int New response id.
+ */
+ public function existing_response_action(object $response, int $userid): int {
+ $responses = $this->questionnaire->responses();
+ $responses->response_delete($response->rid, $response->sec);
+ return $responses->response_insert($response, $userid);
+ }
+
+ /**
+ * Validate and save the current page, then return the next page number (or an error string).
+ *
+ * @param object $response Response data object.
+ * @param int $userid
+ * @return int|bool|string Next section number, false if no next page, or error message string.
+ */
+ public function next_page_action(object $response, int $userid): int|bool|string {
+ $msg = $this->questionnaire->responses()->response_check_format(
+ $response->sec,
+ $response,
+ true,
+ true,
+ $this->questionnaire->questions()
+ );
+ if (!empty($msg)) {
+ return $msg;
+ }
+ $response->rid = $this->existing_response_action($response, $userid);
+ return $this->questionnaire->navigator()->next_page($response->sec, $response->rid);
+ }
+
+ /**
+ * Save the current page and return the previous page number.
+ *
+ * @param object $response Response data object.
+ * @param int $userid
+ * @return int|bool Previous section number, or false if none.
+ */
+ public function previous_page_action(object $response, int $userid): int|bool {
+ $response->rid = $this->existing_response_action($response, $userid);
+ return $this->questionnaire->navigator()->prev_page($response->sec, $response->rid);
+ }
+}
diff --git a/classes/submission_notifier.php b/classes/submission_notifier.php
new file mode 100644
index 000000000..66d171538
--- /dev/null
+++ b/classes/submission_notifier.php
@@ -0,0 +1,312 @@
+.
+
+namespace mod_questionnaire;
+
+use stdClass;
+
+/**
+ * Notification dispatcher for questionnaire submissions.
+ *
+ * Encapsulates the post-submit fan-out: emailing the survey-defined address,
+ * messaging users with the submissionnotification capability, and formatting
+ * the submission body for both delivery channels.
+ *
+ * @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 submission_notifier {
+ /** @var questionnaire The questionnaire whose submissions this notifier handles. */
+ private questionnaire $questionnaire;
+
+ /**
+ * Constructor.
+ *
+ * @param questionnaire $questionnaire
+ */
+ public function __construct(questionnaire $questionnaire) {
+ $this->questionnaire = $questionnaire;
+ }
+
+ /**
+ * Notify subscribers that a response has been submitted.
+ *
+ * Sends the survey-configured email and, when the questionnaire's notifications
+ * setting is non-zero, dispatches a Moodle message to each capable user.
+ *
+ * @param int $rid The response id.
+ * @return bool True if all delivery attempts succeeded.
+ */
+ public function notify(int $rid): bool {
+ $success = true;
+ $email = $this->questionnaire->survey()->email();
+ if (!empty($email)) {
+ $success = $this->send_email($rid, $email);
+ }
+ if ($this->questionnaire->notifications()) {
+ $success = $this->send_notifications($rid) && $success;
+ }
+ return $success;
+ }
+
+ /**
+ * Return users who should be notified of a new submission by $userid.
+ *
+ * In SEPARATEGROUPS mode only users who share a group with $userid are notified.
+ * Users with no group are notified only if the submitter also has no group.
+ *
+ * @param int $userid Submitting user id.
+ * @return array Indexed by user id.
+ */
+ public function get_notifiable_users(int $userid): array {
+ $potentialusers = get_enrolled_users(
+ $this->questionnaire->context(),
+ 'mod/questionnaire:submissionnotification',
+ null,
+ 'u.*',
+ null,
+ null,
+ null,
+ true
+ );
+
+ $cm = $this->questionnaire->coursemodule();
+ $notifiableusers = [];
+ if (groups_get_activity_groupmode($cm) == SEPARATEGROUPS) {
+ if ($groups = groups_get_all_groups($this->questionnaire->courseid(), $userid, $cm->groupingid)) {
+ foreach ($groups as $group) {
+ foreach ($potentialusers as $potentialuser) {
+ if ($potentialuser->id == $userid) {
+ continue;
+ }
+ if (groups_is_member($group->id, $potentialuser->id)) {
+ $notifiableusers[$potentialuser->id] = $potentialuser;
+ }
+ }
+ }
+ } else {
+ foreach ($potentialusers as $potentialuser) {
+ if ($potentialuser->id == $userid) {
+ continue;
+ }
+ if (!groups_has_membership($cm, $potentialuser->id)) {
+ $notifiableusers[$potentialuser->id] = $potentialuser;
+ }
+ }
+ }
+ } else {
+ foreach ($potentialusers as $potentialuser) {
+ if ($potentialuser->id == $userid) {
+ continue;
+ }
+ $notifiableusers[$potentialuser->id] = $potentialuser;
+ }
+ }
+ return $notifiableusers;
+ }
+
+ /**
+ * Send a Moodle message to each user with the submissionnotification capability.
+ *
+ * @param int $rid The response id.
+ * @return bool
+ */
+ private function send_notifications(int $rid): bool {
+ global $CFG, $USER;
+
+ $this->questionnaire->add_response($rid);
+ $message = '';
+ if ($this->questionnaire->notifications() == 2) {
+ $message .= $this->build_full_submission_message($rid);
+ }
+
+ $success = true;
+ if ($notifyusers = $this->get_notifiable_users($USER->id)) {
+ $info = new stdClass();
+ if ($this->questionnaire->respondenttype() != 'anonymous') {
+ $info->userfrom = $USER;
+ $info->username = fullname($info->userfrom, true);
+ $info->profileurl = $CFG->wwwroot . '/user/view.php?id=' . $info->userfrom->id .
+ '&course=' . $this->questionnaire->courseid();
+ $langstringtext = 'submissionnotificationtextuser';
+ $langstringhtml = 'submissionnotificationhtmluser';
+ } else {
+ $info->userfrom = \core_user::get_noreply_user();
+ $info->username = '';
+ $info->profileurl = '';
+ $langstringtext = 'submissionnotificationtextanon';
+ $langstringhtml = 'submissionnotificationhtmlanon';
+ }
+ $info->name = format_string($this->questionnaire->name());
+ $info->submissionurl = $CFG->wwwroot . '/mod/questionnaire/report.php?action=vresp&sid=' .
+ $this->questionnaire->surveyid() . '&rid=' . $rid . '&instance=' . $this->questionnaire->id();
+ $info->coursename = format_string($this->questionnaire->course()->fullname);
+ $info->postsubject = get_string('submissionnotificationsubject', 'questionnaire');
+ $info->posttext = get_string($langstringtext, 'questionnaire', $info);
+ $info->posthtml = '
' . get_string($langstringhtml, 'questionnaire', $info) . '
';
+ if (!empty($message)) {
+ $info->posttext .= html_to_text($message);
+ $info->posthtml .= $message;
+ }
+ foreach ($notifyusers as $notifyuser) {
+ $info->userto = $notifyuser;
+ $this->dispatch_message($info, 'notification');
+ }
+ }
+ return $success;
+ }
+
+ /**
+ * Format every question and answer for a single submission as HTML for inline message bodies.
+ *
+ * @param int $rid The response id.
+ * @return string
+ */
+ private function build_full_submission_message(int $rid): string {
+ $responses = $this->questionnaire->responses()->get_full_submission_for_export($rid);
+ $message = '';
+ foreach ($responses as $response) {
+ $message .= html_to_text(format_string($response->questionname)) . "
\n";
+ $message .= get_string('question') . ': ' . html_to_text(format_string($response->questiontext)) . "
\n";
+ $message .= get_string('answers', 'questionnaire') . ":
\n";
+ foreach ($response->answers as $answer) {
+ $message .= html_to_text($answer) . "
\n";
+ }
+ $message .= "
\n";
+ }
+ return $message;
+ }
+
+ /**
+ * Send the full response submission to the survey-configured email recipient list.
+ *
+ * @param int $rid The response id.
+ * @param string $email Comma- or semicolon-separated list of addresses.
+ * @return bool
+ */
+ private function send_email(int $rid, string $email): bool {
+ global $CFG;
+
+ $submission = $this->questionnaire->reporter()->generate_csv(0, (string)$rid, '', null, 1);
+ if (!empty($submission)) {
+ $answers = $this->format_answers_for_email($submission);
+ } else {
+ $answers = ['html' => '', 'plaintext' => ''];
+ }
+
+ $name = s($this->questionnaire->name());
+ if (empty($email)) {
+ return false;
+ }
+
+ $endhtml = "\r\n
";
+ $endplaintext = "\r\n";
+
+ $subject = get_string('surveyresponse', 'questionnaire') . ": $name [$rid]";
+ $url = $CFG->wwwroot . '/mod/questionnaire/report.php?action=vresp&sid=' .
+ $this->questionnaire->surveyid() . '&rid=' . $rid . '&instance=' . $this->questionnaire->id();
+
+ $bodyhtml = '
' . $url . ' ' . $endhtml;
+ $bodyplaintext = $url . $endplaintext;
+ $bodyhtml .= get_string('surveyresponse', 'questionnaire') . ' "' . $name . '"' . $endhtml;
+ $bodyplaintext .= get_string('surveyresponse', 'questionnaire') . ' "' . $name . '"' . $endplaintext;
+ $bodyhtml .= $answers['html'];
+ $bodyplaintext .= $answers['plaintext'];
+
+ $altbody = "\n$bodyplaintext\n";
+ $return = true;
+ $userfrom = \core_user::get_noreply_user();
+ foreach (preg_split('/,|;/', $email) as $addr) {
+ $userto = new stdClass();
+ $userto->email = trim($addr);
+ $userto->mailformat = 1;
+ $userto->id = -10;
+ if (!email_to_user($userto, $userfrom, $subject, $altbody, $bodyhtml)) {
+ $return = false;
+ }
+ }
+ return $return;
+ }
+
+ /**
+ * Format submission answers for email delivery.
+ *
+ * @param array $answers Raw answers array from generate_csv.
+ * @return array Keys 'plaintext' and 'html'.
+ */
+ private function format_answers_for_email(array $answers): array {
+ global $USER;
+
+ $formatted = ['plaintext' => '', 'html' => ''];
+ // The generate_csv result is an array of rows; we need at least a header row (0) and
+ // a data row (1). Anything less means there is nothing to format.
+ if (count($answers) < 2) {
+ return $formatted;
+ }
+
+ $endhtml = "\r\n
";
+ $endplaintext = "\r\n";
+ reset($answers);
+
+ for ($i = 0; $i < count($answers[0]); $i++) {
+ $sep = ' : ';
+ switch ($i) {
+ case 1:
+ $sep = ' ';
+ break;
+ case 4:
+ $formatted['plaintext'] .= get_string('user') . ' ';
+ $formatted['html'] .= get_string('user') . ' ';
+ break;
+ case 6:
+ if ($this->questionnaire->respondenttype() != 'anonymous') {
+ $formatted['html'] .= get_string('email') . $sep . $USER->email . $endhtml;
+ $formatted['plaintext'] .= get_string('email') . $sep . $USER->email . $endplaintext;
+ }
+ }
+ $formatted['html'] .= $answers[0][$i] . $sep . $answers[1][$i] . $endhtml;
+ $formatted['plaintext'] .= $answers[0][$i] . $sep . $answers[1][$i] . $endplaintext;
+ }
+ return $formatted;
+ }
+
+ /**
+ * Send a message via Moodle messaging.
+ *
+ * @param object $info Message details (userfrom, userto, postsubject, posttext, posthtml, submissionurl, name).
+ * @param string $eventtype Message type identifier.
+ */
+ private function dispatch_message(object $info, string $eventtype): void {
+ $eventdata = new \core\message\message();
+ $eventdata->courseid = $this->questionnaire->courseid();
+ $eventdata->modulename = 'questionnaire';
+ $eventdata->userfrom = $info->userfrom;
+ $eventdata->userto = $info->userto;
+ $eventdata->subject = $info->postsubject;
+ $eventdata->fullmessage = $info->posttext;
+ $eventdata->fullmessageformat = FORMAT_PLAIN;
+ $eventdata->fullmessagehtml = $info->posthtml;
+ $eventdata->smallmessage = $info->postsubject;
+ $eventdata->name = $eventtype;
+ $eventdata->component = 'mod_questionnaire';
+ $eventdata->notification = 1;
+ $eventdata->contexturl = $info->submissionurl;
+ $eventdata->contexturlname = $info->name;
+ message_send($eventdata);
+ }
+}
diff --git a/classes/survey_settings_controller.php b/classes/survey_settings_controller.php
new file mode 100644
index 000000000..e83329c65
--- /dev/null
+++ b/classes/survey_settings_controller.php
@@ -0,0 +1,93 @@
+.
+
+namespace mod_questionnaire;
+
+/**
+ * Controller for the qsettings.php save flow.
+ *
+ * Converts the survey settings form data into an explicit allowlist of survey
+ * fields, processes the info / thank-you editor draft areas, and persists the
+ * result via {@see survey::update_settings()}.
+ *
+ * @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 survey_settings_controller {
+ /**
+ * Constructor.
+ *
+ * @param questionnaire $questionnaire The active questionnaire.
+ */
+ public function __construct(
+ /** @var questionnaire The active questionnaire. */
+ private readonly questionnaire $questionnaire,
+ ) {
+ }
+
+ /**
+ * Persist survey settings from parsed qsettings form data.
+ *
+ * @param \stdClass $formdata Validated form data returned by settings_form::get_data().
+ * @return int Survey id on success.
+ */
+ public function save(\stdClass $formdata): int {
+ $fields = [
+ 'name' => $formdata->name ?? '',
+ 'realm' => $formdata->realm ?? '',
+ 'title' => $formdata->title ?? '',
+ 'subtitle' => $formdata->subtitle ?? '',
+ 'info' => $this->save_editor($formdata, 'info'),
+ 'theme' => '',
+ 'thankspage' => $formdata->thankspage ?? '',
+ 'thankhead' => $formdata->thankhead ?? '',
+ 'thankbody' => $this->save_editor($formdata, 'thankbody'),
+ 'email' => $formdata->email ?? '',
+ 'courseid' => isset($formdata->courseid) ? (int) $formdata->courseid : null,
+ ];
+
+ $result = $this->questionnaire->survey()->update_settings($fields);
+ if ($result === false) {
+ throw new \moodle_exception('couldnotcreatenewsurvey', 'mod_questionnaire');
+ }
+ return $result;
+ }
+
+ /**
+ * Save a draft-area editor field and return its processed body text.
+ *
+ * @param \stdClass $formdata
+ * @param string $area File area name (matches the editor element name).
+ * @return string
+ */
+ private function save_editor(\stdClass $formdata, string $area): string {
+ $value = $formdata->$area ?? null;
+ if (!is_array($value)) {
+ return '';
+ }
+ return file_save_draft_area_files(
+ (int) $value['itemid'],
+ $this->questionnaire->context()->id,
+ 'mod_questionnaire',
+ $area,
+ $this->questionnaire->surveyid(),
+ ['subdirs' => true],
+ $value['text']
+ );
+ }
+}
diff --git a/classes/task/cleanup.php b/classes/task/cleanup.php
index 01fa65576..481c39411 100644
--- a/classes/task/cleanup.php
+++ b/classes/task/cleanup.php
@@ -38,13 +38,10 @@ public function get_name() {
* Execute method.
*/
public function execute() {
- global $CFG;
- require_once($CFG->dirroot . '/mod/questionnaire/locallib.php');
-
- questionnaire_cleanup();
+ \mod_questionnaire\local\survey\survey::cleanup_orphans();
$isautodelete = (bool) get_config('questionnaire', 'autodeleteresponse');
if ($isautodelete) {
- questionnaire_delete_old_responses();
+ \mod_questionnaire\local\response\questionnaire_responses::delete_old_responses();
}
}
}
diff --git a/classes/task/cron_task.php b/classes/task/cron_task.php
index 0b41ececa..389563122 100644
--- a/classes/task/cron_task.php
+++ b/classes/task/cron_task.php
@@ -15,9 +15,7 @@
// along with Moodle. If not, see
.
namespace mod_questionnaire\task;
-defined('MOODLE_INTERNAL') || die();
-require_once($CFG->dirroot . '/mod/questionnaire/questionnaire.class.php');
/**
* A schedule task for mod_questionnaire cron.
*
@@ -40,14 +38,14 @@ public function get_name() {
*/
public function execute() {
global $DB;
- $rangetimecrontask = questionnaire_get_range_time_permanently();
+ $rangetimecrontask = \mod_questionnaire\local\survey\survey::question_deletion_duration();
$sql = "SELECT *
FROM {questionnaire_question}
WHERE deleted IS NOT NULL
AND deleted < ?";
if ($deletequestions = $DB->get_records_sql($sql, [time() - $rangetimecrontask])) {
foreach ($deletequestions as $question) {
- questionnaire_delete_permanently_questions($question->id, $question->surveyid);
+ \mod_questionnaire\local\survey\survey::delete_question_permanently($question->id, $question->surveyid);
}
}
}
diff --git a/complete.php b/complete.php
index 3e59a8d9a..af19a132b 100644
--- a/complete.php
+++ b/complete.php
@@ -26,27 +26,25 @@
require_once("../../config.php");
require_once($CFG->libdir . '/completionlib.php');
-require_once($CFG->dirroot . '/mod/questionnaire/questionnaire.class.php');
-if (!isset($SESSION->questionnaire)) {
- $SESSION->questionnaire = new stdClass();
-}
-$SESSION->questionnaire->current_tab = 'view';
+use mod_questionnaire\questionnaire;
+use mod_questionnaire\output\completepage;
$id = optional_param('id', null, PARAM_INT); // Course Module ID.
$a = optional_param('a', null, PARAM_INT); // Questionnaire ID.
-
-$sid = optional_param('sid', null, PARAM_INT); // Survey id.
$resume = optional_param('resume', null, PARAM_INT); // Is this attempt a resume of a saved attempt?
-[$cm, $course, $questionnaire] = questionnaire_get_standard_page_items($id, $a);
+if (!empty($id)) {
+ $questionnaire = questionnaire::from_cmid($id);
+} else {
+ $questionnaire = questionnaire::from_instanceid($a);
+}
// Check login and get context.
-require_course_login($course, true, $cm);
-$context = context_module::instance($cm->id);
-require_capability('mod/questionnaire:view', $context);
+require_course_login($questionnaire->course(), true, $questionnaire->coursemodule());
+require_capability('mod/questionnaire:view', $questionnaire->context());
-$url = new moodle_url($CFG->wwwroot . '/mod/questionnaire/complete.php');
+$url = new moodle_url('/mod/questionnaire/complete.php');
if (isset($id)) {
$url->param('id', $id);
} else {
@@ -54,35 +52,30 @@
}
$PAGE->set_url($url);
-$PAGE->set_context($context);
-$questionnaire = new questionnaire($course, $cm, 0, $questionnaire);
-// Add renderer and page objects to the questionnaire object for display use.
-$questionnaire->add_renderer($PAGE->get_renderer('mod_questionnaire'));
-$questionnaire->add_page(new \mod_questionnaire\output\completepage());
+$PAGE->set_context($questionnaire->context());
+$PAGE->set_title(format_string($questionnaire->name()));
+$PAGE->set_heading(format_string($questionnaire->course()->fullname));
-$questionnaire->strquestionnaires = get_string("modulenameplural", "questionnaire");
-$questionnaire->strquestionnaire = get_string("modulename", "questionnaire");
+$renderer = $PAGE->get_renderer('mod_questionnaire');
+$page = new completepage();
// Mark as viewed.
-$completion = new completion_info($course);
-$completion->set_module_viewed($cm);
+$completion = new completion_info($questionnaire->course());
+$completion->set_module_viewed($questionnaire->coursemodule());
if ($resume) {
- $context = context_module::instance($questionnaire->cm->id);
- $anonymous = $questionnaire->respondenttype == 'anonymous';
+ $anonymous = $questionnaire->is_anonymous();
$event = \mod_questionnaire\event\attempt_resumed::create([
- 'objectid' => $questionnaire->id,
+ 'objectid' => $questionnaire->id(),
'anonymous' => $anonymous,
- 'context' => $context,
+ 'context' => context_module::instance($questionnaire->coursemodule()->id),
]);
$event->trigger();
}
-// Generate the view HTML in the page.
-$questionnaire->view();
+(new \mod_questionnaire\local\survey\survey_view_builder($renderer, $page))->build_view($questionnaire, $USER->id);
-// Output the page.
-echo $questionnaire->renderer->header();
-echo $questionnaire->renderer->render($questionnaire->page);
-echo $questionnaire->renderer->footer($course);
+echo $renderer->header();
+echo $renderer->render($page);
+echo $renderer->footer($questionnaire->course());
diff --git a/db/install.php b/db/install.php
index 8cc9130d0..799dd3470 100644
--- a/db/install.php
+++ b/db/install.php
@@ -33,91 +33,91 @@ function xmldb_questionnaire_install() {
$questiontype = new stdClass();
$questiontype->typeid = 1;
$questiontype->type = 'Yes/No';
- $questiontype->has_choices = 'n';
- $questiontype->response_table = 'response_bool';
+ $questiontype->haschoices = 'n';
+ $questiontype->responsetable = 'response_bool';
$id = $DB->insert_record('questionnaire_question_type', $questiontype);
$questiontype = new stdClass();
$questiontype->typeid = 2;
$questiontype->type = 'Text Box';
- $questiontype->has_choices = 'n';
- $questiontype->response_table = 'response_text';
+ $questiontype->haschoices = 'n';
+ $questiontype->responsetable = 'response_text';
$id = $DB->insert_record('questionnaire_question_type', $questiontype);
$questiontype = new stdClass();
$questiontype->typeid = 3;
$questiontype->type = 'Essay Box';
- $questiontype->has_choices = 'n';
- $questiontype->response_table = 'response_text';
+ $questiontype->haschoices = 'n';
+ $questiontype->responsetable = 'response_text';
$id = $DB->insert_record('questionnaire_question_type', $questiontype);
$questiontype = new stdClass();
$questiontype->typeid = 4;
$questiontype->type = 'Radio Buttons';
- $questiontype->has_choices = 'y';
- $questiontype->response_table = 'resp_single';
+ $questiontype->haschoices = 'y';
+ $questiontype->responsetable = 'resp_single';
$id = $DB->insert_record('questionnaire_question_type', $questiontype);
$questiontype = new stdClass();
$questiontype->typeid = 5;
$questiontype->type = 'Check Boxes';
- $questiontype->has_choices = 'y';
- $questiontype->response_table = 'resp_multiple';
+ $questiontype->haschoices = 'y';
+ $questiontype->responsetable = 'resp_multiple';
$id = $DB->insert_record('questionnaire_question_type', $questiontype);
$questiontype = new stdClass();
$questiontype->typeid = 6;
$questiontype->type = 'Dropdown Box';
- $questiontype->has_choices = 'y';
- $questiontype->response_table = 'resp_single';
+ $questiontype->haschoices = 'y';
+ $questiontype->responsetable = 'resp_single';
$id = $DB->insert_record('questionnaire_question_type', $questiontype);
$questiontype = new stdClass();
$questiontype->typeid = 8;
$questiontype->type = 'Rate (scale 1..5)';
- $questiontype->has_choices = 'y';
- $questiontype->response_table = 'response_rank';
+ $questiontype->haschoices = 'y';
+ $questiontype->responsetable = 'response_rank';
$id = $DB->insert_record('questionnaire_question_type', $questiontype);
$questiontype = new stdClass();
$questiontype->typeid = 9;
$questiontype->type = 'Date';
- $questiontype->has_choices = 'n';
- $questiontype->response_table = 'response_date';
+ $questiontype->haschoices = 'n';
+ $questiontype->responsetable = 'response_date';
$id = $DB->insert_record('questionnaire_question_type', $questiontype);
$questiontype = new stdClass();
$questiontype->typeid = 10;
$questiontype->type = 'Numeric';
- $questiontype->has_choices = 'n';
- $questiontype->response_table = 'response_text';
+ $questiontype->haschoices = 'n';
+ $questiontype->responsetable = 'response_text';
$id = $DB->insert_record('questionnaire_question_type', $questiontype);
$questiontype = new stdClass();
$questiontype->typeid = 11;
$questiontype->type = 'Slider';
- $questiontype->has_choices = 'n';
- $questiontype->response_table = 'response_text';
+ $questiontype->haschoices = 'n';
+ $questiontype->responsetable = 'response_text';
$id = $DB->insert_record('questionnaire_question_type', $questiontype);
$questiontype = new stdClass();
$questiontype->typeid = 12;
$questiontype->type = 'File';
- $questiontype->has_choices = 'n';
- $questiontype->response_table = 'response_file';
+ $questiontype->haschoices = 'n';
+ $questiontype->responsetable = 'response_file';
$id = $DB->insert_record('questionnaire_question_type', $questiontype);
$questiontype = new stdClass();
$questiontype->typeid = 99;
$questiontype->type = 'Page Break';
- $questiontype->has_choices = 'n';
- $questiontype->response_table = '';
+ $questiontype->haschoices = 'n';
+ $questiontype->responsetable = '';
$id = $DB->insert_record('questionnaire_question_type', $questiontype);
$questiontype = new stdClass();
$questiontype->typeid = 100;
$questiontype->type = 'Section Text';
- $questiontype->has_choices = 'n';
- $questiontype->response_table = '';
+ $questiontype->haschoices = 'n';
+ $questiontype->responsetable = '';
$id = $DB->insert_record('questionnaire_question_type', $questiontype);
}
diff --git a/db/install.xml b/db/install.xml
index 7c8fd9768..7db41e9cd 100644
--- a/db/install.xml
+++ b/db/install.xml
@@ -13,8 +13,8 @@
-
-
+
+
@@ -34,7 +34,7 @@
-
+
@@ -49,13 +49,13 @@
-
-
-
+
+
+
-
+
@@ -70,8 +70,8 @@
-
-
+
+
@@ -90,13 +90,13 @@
@@ -104,8 +104,8 @@
-
-
+
+
@@ -129,108 +129,108 @@
-
-
+
+
@@ -238,7 +238,7 @@
-
+
diff --git a/db/services.php b/db/services.php
index 182d4bf04..d74d10b39 100644
--- a/db/services.php
+++ b/db/services.php
@@ -36,9 +36,7 @@
$functions = [
'mod_questionnaire_submit_questionnaire_response' => [
- 'classname' => 'mod_questionnaire\external',
- 'methodname' => 'submit_questionnaire_response',
- 'classpath' => 'mod/questionnaire/externallib.php',
+ 'classname' => 'mod_questionnaire\external\submit_questionnaire_response',
'description' => 'Questionnaire submit',
'type' => 'write',
'capabilities' => 'mod/questionnaire:submit',
diff --git a/db/upgrade.php b/db/upgrade.php
index 5f02d1085..90aa9be22 100644
--- a/db/upgrade.php
+++ b/db/upgrade.php
@@ -408,7 +408,6 @@ function xmldb_questionnaire_upgrade($oldversion = 0) {
// Replace the = separator with :: separator in quest_choice content.
// This fixes radio button options using old "value"="display" formats.
- require_once($CFG->dirroot . '/mod/questionnaire/locallib.php');
$choices = $DB->get_recordset('questionnaire_quest_choice', null);
$total = $DB->count_records('questionnaire_quest_choice');
if ($total > 0) {
@@ -419,7 +418,7 @@ function xmldb_questionnaire_upgrade($oldversion = 0) {
($choice->value == null || $choice->value == 'NULL') &&
!preg_match("/^([0-9]{1,3}=.*|!other=.*)$/", $choice->content)
) {
- $content = questionnaire_choice_values($choice->content);
+ $content = \mod_questionnaire\local\question\question::parse_choice_content($choice->content);
if (strpos($content->text, '=')) {
$newcontent = str_replace('=', '::', $content->text);
$choice->content = $newcontent;
@@ -836,7 +835,7 @@ function xmldb_questionnaire_upgrade($oldversion = 0) {
}
// Need to move rank named degree choices to the new field.
- \mod_questionnaire\question\rate::move_all_nameddegree_choices();
+ \mod_questionnaire\local\question\rate::move_all_nameddegree_choices();
// Questionnaire savepoint reached.
upgrade_mod_savepoint(true, 2018110103, 'questionnaire');
@@ -1090,49 +1089,389 @@ function xmldb_questionnaire_upgrade($oldversion = 0) {
upgrade_mod_savepoint(true, 2025041400.03, 'questionnaire');
}
- return true;
-}
+ if ($oldversion < 2025111100.01) {
+ // Setup table for renaming fields.
+ $table = new xmldb_table('questionnaire');
-/**
- * Supporting functions used once.
- * @return bool
- */
-function questionnaire_upgrade_2007120101() {
- global $DB;
+ // Rename field resp_eligible on table questionnaire to respeligible.
+ $field = new xmldb_field('resp_eligible', XMLDB_TYPE_CHAR, '8', null, XMLDB_NOTNULL, null, 'all', 'respondenttype');
+ // Launch rename field respeligible.
+ $dbman->rename_field($table, $field, 'respeligible');
+ // Define index respview (not unique) to be dropped form questionnaire.
+ $index = new xmldb_index('respview', XMLDB_INDEX_NOTUNIQUE, ['resp_view']);
+ // Conditionally launch drop index respview.
+ if ($dbman->index_exists($table, $index)) {
+ $dbman->drop_index($table, $index);
+ }
- $dbman = $DB->get_manager(); // Loads ddl manager and xmldb classes.
- $status = true;
-
- // Shorten table names to bring them in accordance with the XML DB schema.
- $qtable = new xmldb_table('questionnaire_question_choice');
- $dbman->rename_table($qtable, 'questionnaire_quest_choice', false);
- unset($qtable);
-
- $qtable = new xmldb_table('questionnaire_response_multiple');
- $dbman->rename_table($qtable, 'questionnaire_resp_multiple', false);
- unset($qtable);
-
- $qtable = new xmldb_table('questionnaire_response_single');
- $dbman->rename_table($qtable, 'questionnaire_resp_single', false);
- unset($qtable);
-
- // Upgrade the questionnaire_question_type table to use typeid.
- $table = new xmldb_table('questionnaire_question_type');
- $field = new xmldb_field('typeid');
- $field->set_attributes(XMLDB_TYPE_CHAR, '20', true, true, false, false, null, '0', 'id');
- $dbman->add_field($table, $field);
- if (($numrecs = $dbman->count_records('questionnaire_question_type')) > 0) {
- $recstart = 0;
- $recstoget = 100;
- while ($recstart < $numrecs) {
- if ($records = $dbman->get_records('questionnaire_question_type', [], '', '*', $recstart, $recstoget)) {
- foreach ($records as $record) {
- $dbman->set_field('questionnaire_question_type', 'typeid', $record->id, ['id' => $record->id]);
- }
- }
- $recstart += $recstoget;
+ // Rename field resp_view on table questionnaire to respview.
+ $field = new xmldb_field('resp_view', XMLDB_TYPE_INTEGER, '2', null, XMLDB_NOTNULL, null, '0', 'respeligible');
+ // Launch rename field resp_view.
+ $dbman->rename_field($table, $field, 'respview');
+ // Define index respview (not unique) to be added to questionnaire.
+ $index = new xmldb_index('respview', XMLDB_INDEX_NOTUNIQUE, ['respview']);
+ // Conditionally launch add index respview.
+ if (!$dbman->index_exists($table, $index)) {
+ $dbman->add_index($table, $index);
+ }
+
+ // Setup table for renaming fields.
+ $table = new xmldb_table('questionnaire_survey');
+
+ // Rename field thanks_page on table questionnaire_survey to thankspage.
+ $field = new xmldb_field('thanks_page', XMLDB_TYPE_CHAR, '255', null, null, null, null, 'theme');
+ // Launch rename field thankspage.
+ $dbman->rename_field($table, $field, 'thankspage');
+
+ // Rename field thanks_page on table questionnaire_survey to thankspage.
+ $field = new xmldb_field('thank_head', XMLDB_TYPE_CHAR, '255', null, null, null, null, 'thankspage');
+ // Launch rename field thank_head.
+ $dbman->rename_field($table, $field, 'thankhead');
+
+ // Rename field thank_body on table questionnaire_survey to thankbody.
+ $field = new xmldb_field('thank_body', XMLDB_TYPE_TEXT, null, null, null, null, null, 'thankhead');
+ // Launch rename field thank_body.
+ $dbman->rename_field($table, $field, 'thankbody');
+
+ // Rename field chart_type on table questionnaire_survey to charttype.
+ $field = new xmldb_field('chart_type', XMLDB_TYPE_CHAR, '64', null, null, null, null, 'feedbackscores');
+ // Launch rename field chart_type.
+ $dbman->rename_field($table, $field, 'charttype');
+
+ // Questionnaire savepoint reached.
+ upgrade_mod_savepoint(true, 2025111100.01, 'questionnaire');
+ }
+
+ if ($oldversion < 2025111100.02) {
+ $table = new xmldb_table('questionnaire_question');
+
+ // Rename the field from type_id to typeid.
+ $field = new xmldb_field('type_id', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, '0');
+ if ($dbman->field_exists($table, 'type_id')) {
+ $dbman->rename_field($table, $field, 'typeid');
+ }
+
+ // Rename the field from result_id to resultid.
+ $field = new xmldb_field('result_id', XMLDB_TYPE_INTEGER, '10', null, null, null, null);
+ if ($dbman->field_exists($table, 'result_id')) {
+ $dbman->rename_field($table, $field, 'resultid');
+ }
+
+ upgrade_mod_savepoint(true, 2025111100.02, 'questionnaire');
+ }
+
+ if ($oldversion < 2025111100.03) {
+ $table = new xmldb_table('questionnaire_question_type');
+
+ // Rename field has_choices on table questionnaire_question_type to haschoices.
+ if ($dbman->field_exists($table, 'has_choices')) {
+ $field = new xmldb_field('has_choices', XMLDB_TYPE_CHAR, '1', null, XMLDB_NOTNULL, null, 'y', 'type');
+ $dbman->rename_field($table, $field, 'haschoices');
+ }
+
+ // Rename field response_table on table questionnaire_question_type to responsetable.
+ if ($dbman->field_exists($table, 'response_table')) {
+ $field = new xmldb_field('response_table', XMLDB_TYPE_CHAR, '32', null, null, null, null, 'haschoices');
+ $dbman->rename_field($table, $field, 'responsetable');
+ }
+
+ upgrade_mod_savepoint(true, 2025111100.03, 'questionnaire');
+ }
+
+ if ($oldversion < 2025111100.04) {
+ // Rename field question_id on table questionnaire_quest_choice to questionid.
+ $table = new xmldb_table('questionnaire_quest_choice');
+ if ($dbman->field_exists($table, 'question_id')) {
+ // Launch drop key questionid.
+ $key = new xmldb_key('questionid', XMLDB_KEY_FOREIGN, ['question_id'], 'questionnaire_question', ['id']);
+ $dbman->drop_key($table, $key);
+
+ $field = new xmldb_field('question_id', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, '0');
+ $dbman->rename_field($table, $field, 'questionid');
+
+ // Launch add key questionid.
+ $key = new xmldb_key('questionid', XMLDB_KEY_FOREIGN, ['questionid'], 'questionnaire_question', ['id']);
+ $dbman->add_key($table, $key);
+ }
+
+ upgrade_mod_savepoint(true, 2025111100.04, 'questionnaire');
+ }
+
+ if ($oldversion < 2025111100.05) {
+ // Rename underscore fields and appropriate indeces.
+
+ // Update questionnaire_response_bool table.
+ $table = new xmldb_table('questionnaire_response_bool');
+
+ // Define index response_question (not unique) to be dropped form questionnaire_response_bool.
+ $index = new xmldb_index('response_question', XMLDB_INDEX_NOTUNIQUE, ['response_id', 'question_id']);
+ // Conditionally launch drop index response_question.
+ if ($dbman->index_exists($table, $index)) {
+ $dbman->drop_index($table, $index);
+ }
+ // Rename field response_id on table questionnaire_response_bool to responseid.
+ $field = new xmldb_field('response_id', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, '0', 'id');
+ if ($dbman->field_exists($table, 'response_id')) {
+ // Launch rename field response_id.
+ $dbman->rename_field($table, $field, 'responseid');
+ }
+
+ // Rename field question_id on table questionnaire_response_bool to questionid.
+ $field = new xmldb_field('question_id', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, '0', 'responseid');
+ if ($dbman->field_exists($table, 'question_id')) {
+ // Launch rename field question_id.
+ $dbman->rename_field($table, $field, 'questionid');
+ }
+
+ // Rename field choice_id on table questionnaire_response_bool to choiceid.
+ $field = new xmldb_field('choice_id', XMLDB_TYPE_CHAR, '1', null, XMLDB_NOTNULL, null, 'y', 'questionid');
+ if ($dbman->field_exists($table, 'choice_id')) {
+ // Launch rename field choice_id.
+ $dbman->rename_field($table, $field, 'choiceid');
+ }
+
+ // Define index response_question (not unique) to be added to questionnaire_response_bool.
+ $index = new xmldb_index('response_question', XMLDB_INDEX_NOTUNIQUE, ['responseid', 'questionid']);
+ // Conditionally launch add index response_question.
+ if (!$dbman->index_exists($table, $index)) {
+ $dbman->add_index($table, $index);
+ }
+
+ // Update questionnaire_response_date table.
+ $table = new xmldb_table('questionnaire_response_date');
+
+ // Define index response_question (not unique) to be dropped form questionnaire_response_date.
+ $index = new xmldb_index('response_question', XMLDB_INDEX_NOTUNIQUE, ['response_id', 'question_id']);
+ // Conditionally launch drop index response_question.
+ if ($dbman->index_exists($table, $index)) {
+ $dbman->drop_index($table, $index);
+ }
+
+ // Rename field response_id on table questionnaire_response_bool to responseid.
+ $field = new xmldb_field('response_id', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, '0', 'id');
+ if ($dbman->field_exists($table, 'response_id')) {
+ // Launch rename field response_id.
+ $dbman->rename_field($table, $field, 'responseid');
+ }
+
+ // Rename field question_id on table questionnaire_response_bool to questionid.
+ $field = new xmldb_field('question_id', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, '0', 'responseid');
+ if ($dbman->field_exists($table, 'question_id')) {
+ // Launch rename field question_id.
+ $dbman->rename_field($table, $field, 'questionid');
+ }
+
+ // Define index response_question (not unique) to be added to questionnaire_response_date.
+ $index = new xmldb_index('response_question', XMLDB_INDEX_NOTUNIQUE, ['responseid', 'questionid']);
+ // Conditionally launch add index response_question.
+ if (!$dbman->index_exists($table, $index)) {
+ $dbman->add_index($table, $index);
+ }
+
+ // Rename field response_id on table questionnaire_resp_multiple to responseid.
+ $table = new xmldb_table('questionnaire_resp_multiple');
+
+ // Define index response_question (not unique) to be dropped form questionnaire_resp_multiple.
+ $index = new xmldb_index('response_question', XMLDB_INDEX_NOTUNIQUE, ['response_id', 'question_id', 'choice_id']);
+ // Conditionally launch drop index response_question.
+ if ($dbman->index_exists($table, $index)) {
+ $dbman->drop_index($table, $index);
+ }
+
+ // Launch rename field response_id.
+ $field = new xmldb_field('response_id', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, '0', 'id');
+ if ($dbman->field_exists($table, 'response_id')) {
+ $dbman->rename_field($table, $field, 'responseid');
+ }
+
+ // Launch rename field question_id.
+ $field = new xmldb_field('question_id', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, '0', 'responseid');
+ if ($dbman->field_exists($table, 'question_id')) {
+ $dbman->rename_field($table, $field, 'questionid');
+ }
+
+ // Launch rename field choice_id.
+ $field = new xmldb_field('choice_id', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, '0', 'questionid');
+ if ($dbman->field_exists($table, 'choice_id')) {
+ $dbman->rename_field($table, $field, 'choiceid');
+ }
+
+ // Define index response_question (not unique) to be added to questionnaire_resp_multiple.
+ $index = new xmldb_index('response_question', XMLDB_INDEX_NOTUNIQUE, ['responseid', 'questionid', 'choiceid']);
+ // Conditionally launch add index response_question.
+ if (!$dbman->index_exists($table, $index)) {
+ $dbman->add_index($table, $index);
+ }
+
+ // Rename field response_id on table questionnaire_response_other to responseid.
+ $table = new xmldb_table('questionnaire_response_other');
+
+ // Define index response_question (not unique) to be dropped form questionnaire_response_other.
+ $index = new xmldb_index('response_question', XMLDB_INDEX_NOTUNIQUE, ['response_id', 'question_id', 'choice_id']);
+ // Conditionally launch drop index response_question.
+ if ($dbman->index_exists($table, $index)) {
+ $dbman->drop_index($table, $index);
+ }
+
+ // Launch rename field response_id.
+ $field = new xmldb_field('response_id', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, '0', 'id');
+ if ($dbman->field_exists($table, 'response_id')) {
+ $dbman->rename_field($table, $field, 'responseid');
+ }
+
+ // Launch rename field question_id.
+ $field = new xmldb_field('question_id', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, '0', 'responseid');
+ if ($dbman->field_exists($table, 'question_id')) {
+ $dbman->rename_field($table, $field, 'questionid');
+ }
+
+ // Launch rename field choice_id.
+ $field = new xmldb_field('choice_id', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, '0', 'questionid');
+ if ($dbman->field_exists($table, 'choice_id')) {
+ $dbman->rename_field($table, $field, 'choiceid');
+ }
+
+ // Define index response_question (not unique) to be added to questionnaire_response_other.
+ $index = new xmldb_index('response_question', XMLDB_INDEX_NOTUNIQUE, ['responseid', 'questionid', 'choiceid']);
+ // Conditionally launch add index response_question.
+ if (!$dbman->index_exists($table, $index)) {
+ $dbman->add_index($table, $index);
+ }
+
+ // Rename field response_id on table questionnaire_response_rank to responseid.
+ $table = new xmldb_table('questionnaire_response_rank');
+
+ // Define index response_question (not unique) to be dropped form questionnaire_response_rank.
+ $index = new xmldb_index('response_question', XMLDB_INDEX_NOTUNIQUE, ['response_id', 'question_id', 'choice_id']);
+ // Conditionally launch drop index response_question.
+ if ($dbman->index_exists($table, $index)) {
+ $dbman->drop_index($table, $index);
+ }
+
+ // Launch rename field response_id.
+ $field = new xmldb_field('response_id', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, '0', 'id');
+ if ($dbman->field_exists($table, 'response_id')) {
+ $dbman->rename_field($table, $field, 'responseid');
+ }
+
+ // Launch rename field question_id.
+ $field = new xmldb_field('question_id', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, '0', 'responseid');
+ if ($dbman->field_exists($table, 'question_id')) {
+ $dbman->rename_field($table, $field, 'questionid');
+ }
+
+ // Launch rename field choice_id.
+ $field = new xmldb_field('choice_id', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, '0', 'questionid');
+ if ($dbman->field_exists($table, 'choice_id')) {
+ $dbman->rename_field($table, $field, 'choiceid');
+ }
+
+ // Define index response_question (not unique) to be added to questionnaire_resp_multiple.
+ $index = new xmldb_index('response_question', XMLDB_INDEX_NOTUNIQUE, ['responseid', 'questionid', 'choiceid']);
+ // Conditionally launch add index response_question.
+ if (!$dbman->index_exists($table, $index)) {
+ $dbman->add_index($table, $index);
+ }
+
+ // Update questionnaire_resp_single table.
+ $table = new xmldb_table('questionnaire_resp_single');
+
+ // Define index response_question (not unique) to be dropped form questionnaire_resp_single.
+ $index = new xmldb_index('response_question', XMLDB_INDEX_NOTUNIQUE, ['response_id', 'question_id']);
+ // Conditionally launch drop index response_question.
+ if ($dbman->index_exists($table, $index)) {
+ $dbman->drop_index($table, $index);
+ }
+
+ // Rename field response_id on table questionnaire_response_bool to responseid.
+ $field = new xmldb_field('response_id', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, '0', 'id');
+ if ($dbman->field_exists($table, 'response_id')) {
+ // Launch rename field response_id.
+ $dbman->rename_field($table, $field, 'responseid');
+ }
+
+ // Rename field question_id on table questionnaire_response_bool to questionid.
+ $field = new xmldb_field('question_id', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, '0', 'responseid');
+ if ($dbman->field_exists($table, 'question_id')) {
+ // Launch rename field question_id.
+ $dbman->rename_field($table, $field, 'questionid');
+ }
+
+ // Launch rename field choice_id.
+ $field = new xmldb_field('choice_id', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, '0', 'questionid');
+ if ($dbman->field_exists($table, 'choice_id')) {
+ $dbman->rename_field($table, $field, 'choiceid');
+ }
+
+ // Define index response_question (not unique) to be added to questionnaire_resp_single.
+ $index = new xmldb_index('response_question', XMLDB_INDEX_NOTUNIQUE, ['responseid', 'questionid']);
+ // Conditionally launch add index response_question.
+ if (!$dbman->index_exists($table, $index)) {
+ $dbman->add_index($table, $index);
+ }
+
+ // Rename field response_id on table questionnaire_response_text to responseid.
+ $table = new xmldb_table('questionnaire_response_text');
+
+ // Define index response_question (not unique) to be dropped form questionnaire_response_text.
+ $index = new xmldb_index('response_question', XMLDB_INDEX_NOTUNIQUE, ['response_id', 'question_id']);
+ // Conditionally launch drop index response_question.
+ if ($dbman->index_exists($table, $index)) {
+ $dbman->drop_index($table, $index);
+ }
+
+ // Launch rename field response_id.
+ $field = new xmldb_field('response_id', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, '0', 'id');
+ if ($dbman->field_exists($table, 'response_id')) {
+ $dbman->rename_field($table, $field, 'responseid');
+ }
+
+ // Launch rename field question_id.
+ $field = new xmldb_field('question_id', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, '0', 'responseid');
+ if ($dbman->field_exists($table, 'question_id')) {
+ $dbman->rename_field($table, $field, 'questionid');
+ }
+
+ // Define index response_question (not unique) to be added to questionnaire_response_text.
+ $index = new xmldb_index('response_question', XMLDB_INDEX_NOTUNIQUE, ['responseid', 'questionid']);
+ // Conditionally launch add index response_question.
+ if (!$dbman->index_exists($table, $index)) {
+ $dbman->add_index($table, $index);
+ }
+
+ // Update questionnaire_response_file table.
+ $table = new xmldb_table('questionnaire_response_file');
+
+ // Define index response_question (not unique) to be dropped form questionnaire_response_file.
+ $index = new xmldb_index('response_question', XMLDB_INDEX_NOTUNIQUE, ['response_id', 'question_id']);
+ // Conditionally launch drop index response_question.
+ if ($dbman->index_exists($table, $index)) {
+ $dbman->drop_index($table, $index);
}
+
+ // Rename field response_id on table questionnaire_response_bool to responseid.
+ $field = new xmldb_field('response_id', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, '0', 'id');
+ if ($dbman->field_exists($table, 'response_id')) {
+ // Launch rename field response_id.
+ $dbman->rename_field($table, $field, 'responseid');
+ }
+
+ // Rename field question_id on table questionnaire_response_bool to questionid.
+ $field = new xmldb_field('question_id', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, '0', 'responseid');
+ if ($dbman->field_exists($table, 'question_id')) {
+ // Launch rename field question_id.
+ $dbman->rename_field($table, $field, 'questionid');
+ }
+
+ // Define index response_question (not unique) to be added to questionnaire_response_file.
+ $index = new xmldb_index('response_question', XMLDB_INDEX_NOTUNIQUE, ['responseid', 'questionid']);
+ // Conditionally launch add index response_question.
+ if (!$dbman->index_exists($table, $index)) {
+ $dbman->add_index($table, $index);
+ }
+
+ upgrade_mod_savepoint(true, 2025111100.05, 'questionnaire');
}
- return $status;
+ return true;
}
diff --git a/drawchart.php b/drawchart.php
deleted file mode 100644
index da5e208da..000000000
--- a/drawchart.php
+++ /dev/null
@@ -1,582 +0,0 @@
-.
-
-/**
- * Library draw chart function.
- * @package mod_questionnaire
- * @copyright 2016 Mike Churchward (mike.churchward@poetgroup.org)
- * @author Mike Churchward
- * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
- */
-
-/**
- * This is the function.
- * @param string $feedbacktype
- * @param array $labels
- * @param string $groupname
- * @param bool $allresponses
- * @param null|string $charttype
- * @param null|array $score
- * @param null|array $allscore
- * @param null|string $globallabel
- * @return string
- */
-function draw_chart(
- $feedbacktype,
- $labels,
- $groupname,
- $allresponses,
- $charttype = null,
- $score = null,
- $allscore = null,
- $globallabel = null
-) {
- global $PAGE;
-
- $pageoutput = '';
-
- if ($allresponses) {
- $nbvalues = count($allscore);
- } else {
- $nbvalues = count($score);
- }
- $nblabels = count($labels);
- $charttitlefont = "Verdana";
- $charttitlesize = 10;
- $charttitlesize2 = 10;
- if ($PAGE->pagetype == 'mod-questionnaire-myreport') {
- $charttitle = get_string('yourresponse', 'questionnaire');
- } else {
- $charttitle = get_string('thisresponse', 'questionnaire');
- }
- $charttitle2 = $groupname;
-
- // Gradient colors if needed.
- // TODO make gradient colors customizable in the settings.
- $chartcolorsgradient = "['Gradient(white:blue)', 'Gradient(white:red)', 'Gradient(white:green)', 'Gradient(white:pink)',
- 'Gradient(white:yellow)', 'Gradient(white:cyan)', 'Gradient(white:navy)',
- 'Gradient(white:gray)', 'Gradient(white:black)']";
-
- // We do not have labels other than global in this feedback type.
- if ($feedbacktype == 'global') {
- $labels = [$globallabel];
- }
-
- switch ($charttype) {
- case 'bipolar':
- // Global feedback with only one score.
- if ($feedbacktype == 'global') {
- if ($score) {
- if ($allresponses) {
- $score = null;
- } else {
- $score2 = $score;
- $score = [$score2[0]];
- $oppositescore = [$score2[1]];
- }
- }
-
- if ($allscore) {
- $allscore2 = $allscore;
- $allscore = [$allscore2[0]];
- $alloppositescore = [$allscore2[1]];
- }
- $nblabels = 1.5; // For a single horizontal bar with 1.5 height.
- $nbvalues = 1; // Only one hbar.
- } else {
- if ($score) {
- $oppositescore = [];
- foreach ($score as $sc) {
- $oppositescore[] = 100 - $sc;
- }
- }
- if ($allscore) {
- $alloppositescore = [];
- foreach ($allscore as $sc) {
- $alloppositescore[] = 100 - $sc;
- }
- }
- }
- foreach ($labels as $key => $label) {
- $lb = explode("|", $label);
- // Just in case there is no pipe separator in label.
- if (count($lb) > 1) {
- $left = $lb[0];
- $right = $lb[1];
- // Lib core_text and diff needed for non-ascii characters.
- $lenleft = core_text::strlen($left);
- $diffleft = strlen($left) - $lenleft;
- $lenright = core_text::strlen($right);
- $diffright = strlen($right) - $lenright;
- if ($lenleft < $lenright) {
- $padlength = $lenright + $diffleft;
- $left = str_pad($left, $padlength, ' ', STR_PAD_LEFT);
- }
- if ($lenleft > $lenright) {
- $padlength = $lenleft + $diffright;
- $right = str_pad($right, $padlength, ' ', STR_PAD_RIGHT);
- }
- $labels[$key] = $left . ' ' . $right;
- }
- }
- // Find length of longest label.
- $maxlen = 0;
- foreach ($labels as $label) {
- $labellen = core_text::strlen($label);
- if ($labellen > $maxlen) {
- $maxlen = $labellen;
- }
- }
-
- $labels = json_encode($labels, JSON_UNESCAPED_UNICODE);
- // JSON_UNESCAPED_UNICODE available since php 5.4, used to correctly treat French accents etc.
-
- // The bar colors :: use green for "positive" (left column) and pink for "negative" (right column).
- $chartcolors = [];
- $chartcolors2 = [];
- if ($score) {
- for ($i = 0; $i < $nbvalues; $i++) {
- if ($score[$i] != 0) {
- $chartcolors[] = 'lightgreen';
- }
- }
- for ($i = $nbvalues; $i < $nbvalues * 2; $i++) {
- $chartcolors[] = 'pink';
- }
- }
- if ($allscore) {
- for ($i = 0; $i < $nbvalues; $i++) {
- if ($allscore[$i] != 0) {
- $chartcolors2[] = 'lightgreen';
- }
- }
- for ($i = $nbvalues; $i < $nbvalues * 2; $i++) {
- $chartcolors2[] = 'pink';
- }
- }
-
- // Encode all arrays for javascript compatibility.
- $chartcolors = json_encode($chartcolors);
- if ($allscore) {
- $chartcolors2 = json_encode($chartcolors2);
- }
- if ($score) {
- $score = json_encode($score);
- $oppositescore = json_encode($oppositescore);
- }
-
- if ($allscore) {
- $allscore = json_encode($allscore);
- $alloppositescore = json_encode($alloppositescore);
- }
- $canvasheight = ($nblabels * 25) + 60;
- $canvaswidth = max(300, (100 + ($maxlen * 7)));
- if (!$allresponses) {
- $pageoutput .= '
- [No canvas support]
- ';
- }
- if ($allscore) {
- $pageoutput .= '
- [No canvas support]
- ';
- }
- $pageoutput .= '
-
- ';
- break;
-
- case 'hbar':
- // The bar colors.
- $chartcolors = [];
- $chartcolors = json_encode($chartcolors);
- $sequential = 'true';
- // Global feedback with only one score.
- if ($feedbacktype == 'global') {
- $lb = explode("|", $globallabel);
- // Just in case there is no pipe separator in label.
- if (count($lb) > 1) {
- $labels = '';
- $left = $lb[0];
- $right = $lb[1];
- $lenleft = core_text::strlen($left);
- $lenright = core_text::strlen($right);
- if ($lenleft < $lenright) {
- $padlength = $lenright;
- $left = str_pad($left, $padlength, ' ', STR_PAD_LEFT);
- }
- if ($lenleft > $lenright) {
- $padlength = $lenleft;
- $right = str_pad($right, $padlength, ' ', STR_PAD_RIGHT);
- }
- $labels[0] = $left;
- $labels[1] = $right;
- }
- } else {
- if ($nblabels > $nbvalues) {
- for ($i = 1; $i < $nblabels - 1; $i++) {
- unset($labels[$i]);
- }
- }
- $sequential = 'false';
- }
- $nblabels = count($labels) + 1;
- $score = json_encode($score);
- if ($allscore) {
- $allscore = json_encode($allscore);
- }
- // Find length of longest label.
- $maxlen = 0;
- foreach ($labels as $label) {
- $labellen = core_text::strlen($label);
- if ($labellen > $maxlen) {
- $maxlen = $labellen;
- }
- }
- foreach ($labels as $value) {
- $output[] = '"' . $value . '"';
- }
- $labels = '[' . implode(',', $output) . ']';
- $canvasheight = ($nblabels * 20) + 60;
- $charttextfont = 'Courier';
- $gutterleft = ($maxlen * 8) + 5;
- $canvaswidth = 400 + $gutterleft;
- if (!$allresponses) {
- $pageoutput .= '
- [No canvas support]
- ';
- }
- if ($allscore) {
- $pageoutput .= '
- [No canvas support]
- ';
- }
- $pageoutput .= '
-
- ';
- break;
-
- case 'radar':
- $score = json_encode($score);
- foreach ($labels as $key => $label) {
- if ($key != 0) {
- $labels[$key] = wordwrap($label, 20, "\\r\\n");
- } else {
- $labels[$key] = $label . "\\r\\n";
- }
- }
- foreach ($labels as $value) {
- $output[] = '"' . $value . '"';
- }
- $labels = '[' . implode(',', $output) . ']';
- if ($allscore) {
- $allscore = json_encode($allscore);
- }
- if (!$allresponses) {
- $pageoutput .= '
- [No canvas support]
- ';
- }
- if ($allscore) {
- $pageoutput .= '
- [No canvas support]
- ';
- }
- $pageoutput .= '
-
- ';
- break;
-
- case 'rose':
- if ($score != 'null') {
- $score = json_encode($score);
- }
-
- foreach ($labels as $key => $label) {
- $labels[$key] = wordwrap($label, 8, "\\r\\n");
- }
- foreach ($labels as $value) {
- $output[] = '"' . $value . '"';
- }
- $labels = '[' . implode(',', $output) . ']';
-
- if ($allscore) {
- $allscore = json_encode($allscore);
- }
- $size = 400;
- if (!$allresponses) {
- $pageoutput .= '
- [No canvas support]
- ';
- }
- if ($allscore) {
- $pageoutput .= '
- [No canvas support]
- ';
- }
- $pageoutput .= '
-
- ';
- break;
-
- case 'vprogress':
- if (!$allresponses) {
- $score = $score[0];
- } else {
- $score = null;
- }
- $score = json_encode($score);
- if ($allscore) {
- $allscore = json_encode($allscore[0]);
- }
- // Check presence of pipe separator in label.
- $lb = explode("|", $globallabel);
- $maxlen = 0;
- if (count($lb) > 1) {
- $labels = array_reverse($lb);
- // Find length of longest label.
- $maxlen = 0;
- foreach ($labels as $label) {
- $labellen = core_text::strlen($label);
- if ($labellen > $maxlen) {
- $maxlen = $labellen;
- }
- }
- foreach ($labels as $value) {
- $output[] = '"' . $value . '"';
- }
- $labels = '[' . implode(',', $output) . ']';
- } else {
- $labels = '';
- }
- $charttextfont = 'Courier';
- $gutterright = 150 + ($maxlen * 3);
- $canvaswidth = 250 + ($maxlen * 3);
-
- if (!$allresponses) {
- $pageoutput .= '
- [No canvas support]
- ';
- }
- if ($allscore) {
- $pageoutput .= '
- [No canvas support]
- ';
- }
- $pageoutput .= '
-
- ';
- break;
- }
-
- return $pageoutput;
-}
diff --git a/externallib.php b/externallib.php
deleted file mode 100644
index 0b9836f23..000000000
--- a/externallib.php
+++ /dev/null
@@ -1,161 +0,0 @@
-.
-
-/**
- * Questionnaire module external API
- *
- * @package mod_questionnaire
- * @category external
- * @copyright 2018 Igor Sazonov
- * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
- * @since Moodle 3.0
- */
-
-namespace mod_questionnaire;
-
-defined('MOODLE_INTERNAL') || die();
-
-require_once($CFG->libdir . '/externallib.php');
-
-use external_api;
-use external_function_parameters;
-use external_single_structure;
-use external_multiple_structure;
-use external_value;
-use external_warnings;
-
-require_once($CFG->dirroot . '/mod/questionnaire/lib.php');
-require_once($CFG->dirroot . '/mod/questionnaire/questionnaire.class.php');
-
-/**
- * Questionnaire module external functions
- *
- * @package mod_questionnaire
- * @category external
- * @copyright 2018 Igor Sazonov
- * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
- * @since Moodle 3.0
- */
-class external extends external_api {
- /**
- * Describes the parameters for submit_questionnaire_response.
- *
- * @return external_function_parameters
- * @since Moodle 3.0
- */
- public static function submit_questionnaire_response_parameters() {
- return new external_function_parameters(
- [
- 'questionnaireid' => new external_value(PARAM_INT, 'Questionnaire instance id'),
- 'surveyid' => new external_value(PARAM_INT, 'Survey id'),
- 'userid' => new external_value(PARAM_INT, 'User id'),
- 'cmid' => new external_value(PARAM_INT, 'Course module id'),
- 'sec' => new external_value(PARAM_INT, 'Section number'),
- 'completed' => new external_value(PARAM_INT, 'Completed survey or not'),
- 'rid' => new external_value(PARAM_INT, 'Existing response id'),
- 'submit' => new external_value(PARAM_INT, 'Submit survey or not'),
- 'action' => new external_value(PARAM_ALPHA, 'Page action'),
- 'responses' => new external_multiple_structure(
- new external_single_structure(
- [
- 'name' => new external_value(PARAM_RAW, 'data key'),
- 'value' => new external_value(PARAM_RAW, 'data value'),
- ]
- ),
- 'The data to be saved',
- VALUE_DEFAULT,
- []
- ),
- ]
- );
- }
-
- /**
- * Submit questionnaire responses
- *
- * @param int $questionnaireid the questionnaire instance id
- * @param int $surveyid Survey id
- * @param int $userid User id
- * @param int $cmid Course module id
- * @param int $sec Section number
- * @param int $completed Completed survey 1/0
- * @param int $rid Already in progress response id.
- * @param int $submit Submit survey?
- * @param string $action
- * @param array $responses the response ids
- * @return array answers information and warnings
- */
- public static function submit_questionnaire_response(
- $questionnaireid,
- $surveyid,
- $userid,
- $cmid,
- $sec,
- $completed,
- $rid,
- $submit,
- $action,
- $responses
- ) {
- self::validate_parameters(
- self::submit_questionnaire_response_parameters(),
- [
- 'questionnaireid' => $questionnaireid,
- 'surveyid' => $surveyid,
- 'userid' => $userid,
- 'cmid' => $cmid,
- 'sec' => $sec,
- 'completed' => $completed,
- 'rid' => $rid,
- 'submit' => $submit,
- 'action' => $action,
- 'responses' => $responses,
- ]
- );
-
- [$cm, $course, $questionnaire] = questionnaire_get_standard_page_items($cmid);
- $questionnaire = new \questionnaire($course, $cm, 0, $questionnaire);
-
- $context = \context_module::instance($cm->id);
- self::validate_context($context);
-
- require_capability('mod/questionnaire:submit', $context);
-
- $result = $questionnaire->save_mobile_data($userid, $sec, $completed, $rid, $submit, $action, $responses);
- $result['submitted'] = true;
- if (isset($result['warnings']) && !empty($result['warnings'])) {
- unset($result['responses']);
- $result['submitted'] = false;
- }
- $result['warnings'] = [];
- return $result;
- }
-
- /**
- * Describes the submit_questionnaire_response return value.
- *
- * @return external_single_structure
- * @since Moodle 3.0
- */
- public static function submit_questionnaire_response_returns() {
- return new external_single_structure(
- [
- 'submitted' => new external_value(PARAM_BOOL, 'submitted', VALUE_REQUIRED, false, false),
- 'warnings' => new external_warnings(),
- ]
- );
- }
-}
diff --git a/fbsections.php b/fbsections.php
index e0638906a..631a92409 100644
--- a/fbsections.php
+++ b/fbsections.php
@@ -24,7 +24,9 @@
*/
require_once("../../config.php");
-require_once($CFG->dirroot . '/mod/questionnaire/questionnaire.class.php');
+
+use mod_questionnaire\questionnaire;
+use mod_questionnaire\output\feedbackpage;
$id = required_param('id', PARAM_INT); // Course module ID.
$section = optional_param('section', 1, PARAM_INT);
@@ -35,65 +37,47 @@
$action = optional_param('action', '', PARAM_ALPHA);
$sectionid = optional_param('sectionid', 0, PARAM_INT);
-if (! $cm = get_coursemodule_from_id('questionnaire', $id)) {
- throw new \moodle_exception('invalidcoursemodule', 'mod_questionnaire');
-}
-
-if (! $course = $DB->get_record("course", ["id" => $cm->course])) {
- throw new \moodle_exception('coursemisconf', 'mod_questionnaire');
-}
-
-if (! $questionnaire = $DB->get_record("questionnaire", ["id" => $cm->instance])) {
- throw new \moodle_exception('invalidcoursemodule', 'mod_questionnaire');
-}
+$questionnaire = questionnaire::from_cmid($id);
// Needed here for forced language courses.
-require_course_login($course, true, $cm);
-$context = context_module::instance($cm->id);
+require_course_login($questionnaire->course(), true, $questionnaire->coursemodule());
-$url = new moodle_url('/mod/questionnaire/fbsections.php', ['id' => $id]);
-$PAGE->set_url($url);
-$PAGE->set_context($context);
-if (!isset($SESSION->questionnaire)) {
- $SESSION->questionnaire = new stdClass();
-}
+$PAGE->set_url(new moodle_url('/mod/questionnaire/fbsections.php', ['id' => $id]));
+$PAGE->set_context($questionnaire->context());
-$questionnaire = new questionnaire($course, $cm, 0, $questionnaire);
+$surveyid = $questionnaire->surveyid();
if ($sectionid) {
// Get the specified section by its id.
- $feedbacksection = new mod_questionnaire\feedback\section($questionnaire->questions, ['id' => $sectionid]);
-} else if (!$DB->count_records('questionnaire_fb_sections', ['surveyid' => $questionnaire->sid])) {
+ $feedbacksection = new mod_questionnaire\local\feedback\section($questionnaire->questions(), ['id' => $sectionid]);
+} else if (!$DB->count_records('questionnaire_fb_sections', ['surveyid' => $surveyid])) {
// There are no sections currently, so create one.
- if ($questionnaire->survey->feedbacksections == 1) {
+ if ($questionnaire->feedback()->mode() == 1) {
$sectionlabel = get_string('feedbackglobal', 'questionnaire');
} else {
$sectionlabel = get_string('feedbackdefaultlabel', 'questionnaire');
}
- $feedbacksection = mod_questionnaire\feedback\section::new_section($questionnaire->sid, $sectionlabel);
+ $feedbacksection = mod_questionnaire\local\feedback\section::new_section($surveyid, $sectionlabel);
} else {
// Get the specified section by section number.
- $feedbacksection = new mod_questionnaire\feedback\section(
- $questionnaire->questions,
- ['surveyid' => $questionnaire->survey->id, 'sectionnum' => $section]
+ $feedbacksection = new mod_questionnaire\local\feedback\section(
+ $questionnaire->questions(),
+ ['surveyid' => $surveyid, 'sectionnum' => $section]
);
}
// Get all questions that are valid feedback questions.
$validquestions = [];
-foreach ($questionnaire->questions as $question) {
+foreach ($questionnaire->questions() as $question) {
if ($question->valid_feedback()) {
- $validquestions[$question->id] = $question->name;
+ $validquestions[$question->id()] = $question->name();
}
}
-// Add renderer and page objects to the questionnaire object for display use.
-$questionnaire->add_renderer($PAGE->get_renderer('mod_questionnaire'));
-$questionnaire->add_page(new \mod_questionnaire\output\feedbackpage());
-
-$SESSION->questionnaire->current_tab = 'feedback';
+$renderer = $PAGE->get_renderer('mod_questionnaire');
+$page = new feedbackpage();
-if (!$questionnaire->capabilities->editquestions) {
+if (!$questionnaire->capabilities()->can_edit_questions()) {
throw new \moodle_exception('nopermissions', 'mod_questionnaire');
}
@@ -106,31 +90,31 @@
$sectionid = required_param('sectionid', PARAM_INT);
if ($sectionid == $feedbacksection->id) {
$feedbacksection->delete();
- redirect(new moodle_url('/mod/questionnaire/fbsections.php', ['id' => $cm->id]));
+ redirect(new moodle_url('/mod/questionnaire/fbsections.php', ['id' => $questionnaire->coursemodule()->id]));
}
}
$customdata = new stdClass();
$customdata->feedbacksection = $feedbacksection;
$customdata->validquestions = $validquestions;
-$customdata->survey = $questionnaire->survey;
+$customdata->survey = $questionnaire->survey()->to_stdclass();
$customdata->sectionselect = $DB->get_records_menu(
'questionnaire_fb_sections',
- ['surveyid' => $questionnaire->survey->id],
+ ['surveyid' => $surveyid],
'section',
'id,sectionlabel'
);
-$feedbackform = new \mod_questionnaire\feedback_section_form('fbsections.php', $customdata);
+$feedbackform = new \mod_questionnaire\local\feedback\feedback_section_form('fbsections.php', $customdata, $renderer);
$sdata = clone($feedbacksection);
-$sdata->sid = $questionnaire->survey->id;
+$sdata->sid = $surveyid;
$sdata->sectionid = $feedbacksection->id;
-$sdata->id = $cm->id;
+$sdata->id = $questionnaire->coursemodule()->id;
$draftideditor = file_get_submitted_draft_itemid('sectionheading');
$currentinfo = file_prepare_draft_area(
$draftideditor,
- $context->id,
+ $questionnaire->context()->id,
'mod_questionnaire',
'sectionheading',
$feedbacksection->id,
@@ -142,7 +126,7 @@
$feedbackform->set_data($sdata);
if ($feedbackform->is_cancelled()) {
- redirect(new moodle_url('/mod/questionnaire/feedback.php', ['id' => $cm->id]));
+ redirect(new moodle_url('/mod/questionnaire/feedback.php', ['id' => $questionnaire->coursemodule()->id]));
}
if ($settings = $feedbackform->get_data()) {
@@ -154,18 +138,22 @@
redirect(
new moodle_url(
'/mod/questionnaire/fbsections.php',
- ['id' => $cm->id, 'sectionid' => $settings->navigatesections]
+ ['id' => $questionnaire->coursemodule()->id, 'sectionid' => $settings->navigatesections]
)
);
}
} else if (isset($settings->addnewsection)) {
- $newsection = mod_questionnaire\feedback\section::new_section($questionnaire->survey->id, $settings->newsectionlabel);
- redirect(new moodle_url('/mod/questionnaire/fbsections.php', ['id' => $cm->id, 'sectionid' => $newsection->id]));
+ $newsection = mod_questionnaire\local\feedback\section::new_section($surveyid, $settings->newsectionlabel);
+ redirect(new moodle_url(
+ '/mod/questionnaire/fbsections.php',
+ ['id' => $questionnaire->coursemodule()->id, 'sectionid' => $newsection->id]
+ ));
} else if (isset($fullform->confirmdeletesection)) {
redirect(
new moodle_url(
'/mod/questionnaire/fbsections.php',
- ['id' => $cm->id, 'sectionid' => $feedbacksection->id, 'action' => 'confirmdeletesection']
+ ['id' => $questionnaire->coursemodule()->id, 'sectionid' => $feedbacksection->id,
+ 'action' => 'confirmdeletesection']
)
);
} else if (isset($fullform->confirmremovequestion)) {
@@ -173,14 +161,15 @@
redirect(
new moodle_url(
'/mod/questionnaire/fbsections.php',
- ['id' => $cm->id, 'sectionid' => $settings->sectionid, 'action' => 'confirmremovequestion', 'qid' => $qid]
+ ['id' => $questionnaire->coursemodule()->id, 'sectionid' => $settings->sectionid,
+ 'action' => 'confirmremovequestion', 'qid' => $qid]
)
);
} else if (isset($settings->addquestion)) {
$scorecalculation = [];
// Check for added question.
if (isset($settings->addquestionselect) && ($settings->addquestionselect != 0)) {
- if ($questionnaire->questions[$settings->addquestionselect]->supports_feedback_scores()) {
+ if ($questionnaire->questions()[$settings->addquestionselect]->supports_feedback_scores()) {
$scorecalculation[$settings->addquestionselect] = 1;
} else {
$scorecalculation[$settings->addquestionselect] = -1;
@@ -203,7 +192,7 @@
$feedbacksection->sectionlabel = $settings->sectionlabel;
$feedbacksection->sectionheading = file_save_draft_area_files(
(int)$settings->sectionheading['itemid'],
- $context->id,
+ $questionnaire->context()->id,
'mod_questionnaire',
'sectionheading',
$feedbacksection->id,
@@ -255,7 +244,7 @@
$feedbacktext = file_save_draft_area_files(
(int)$settings->feedbacktext[$i]['itemid'],
- $context->id,
+ $questionnaire->context()->id,
'mod_questionnaire',
'feedback',
$fbid,
@@ -268,46 +257,46 @@
// Update all feedback data.
$feedbacksection->update();
}
- $feedbackform = new \mod_questionnaire\feedback_section_form('fbsections.php', $customdata);
+ $feedbackform = new \mod_questionnaire\local\feedback\feedback_section_form('fbsections.php', $customdata, $renderer);
}
// Print the page header.
$PAGE->set_title(get_string('editingfeedback', 'questionnaire'));
-$PAGE->set_heading(format_string($course->fullname));
+$PAGE->set_heading(format_string($questionnaire->course()->fullname));
$PAGE->navbar->add(get_string('editingfeedback', 'questionnaire'));
-echo $questionnaire->renderer->header();
-require('tabs.php');
+echo $renderer->header();
+(new \mod_questionnaire\output\tabs($questionnaire, 'feedback'))->render($page);
// Handle confirmations differently.
if ($action == 'confirmremovequestion') {
$sectionid = required_param('sectionid', PARAM_INT);
$qid = required_param('qid', PARAM_INT);
$msgargs = new stdClass();
- $msgargs->qname = $questionnaire->questions[$qid]->name;
+ $msgargs->qname = $questionnaire->questions()[$qid]->name();
$msgargs->sname = $feedbacksection->sectionlabel;
$msg = '' . get_string('confirmremovequestion', 'questionnaire', $msgargs) . '
';
- $args = ['id' => $questionnaire->cm->id, 'sectionid' => $sectionid];
+ $args = ['id' => $questionnaire->coursemodule()->id, 'sectionid' => $sectionid];
$urlno = new moodle_url('/mod/questionnaire/fbsections.php', $args);
$args['action'] = 'removequestion';
$args['qid'] = $qid;
$urlyes = new moodle_url('/mod/questionnaire/fbsections.php', $args);
$buttonyes = new single_button($urlyes, get_string('yes'));
$buttonno = new single_button($urlno, get_string('no'));
- $questionnaire->page->add_to_page('formarea', $questionnaire->renderer->confirm($msg, $buttonyes, $buttonno));
+ $page->add_to_page('formarea', $renderer->confirm($msg, $buttonyes, $buttonno));
} else if ($action == 'confirmdeletesection') {
$sectionid = required_param('sectionid', PARAM_INT);
$msg = '' .
get_string('confirmdeletesection', 'questionnaire', $feedbacksection->sectionlabel) . '
';
- $args = ['id' => $questionnaire->cm->id, 'sectionid' => $sectionid];
+ $args = ['id' => $questionnaire->coursemodule()->id, 'sectionid' => $sectionid];
$urlno = new moodle_url('/mod/questionnaire/fbsections.php', $args);
$args['action'] = 'deletesection';
$urlyes = new moodle_url('/mod/questionnaire/fbsections.php', $args);
$buttonyes = new single_button($urlyes, get_string('yes'));
$buttonno = new single_button($urlno, get_string('no'));
- $questionnaire->page->add_to_page('formarea', $questionnaire->renderer->confirm($msg, $buttonyes, $buttonno));
+ $page->add_to_page('formarea', $renderer->confirm($msg, $buttonyes, $buttonno));
} else {
- $questionnaire->page->add_to_page('formarea', $feedbackform->render());
+ $page->add_to_page('formarea', $feedbackform->render());
}
-echo $questionnaire->renderer->render($questionnaire->page);
-echo $questionnaire->renderer->footer($course);
+echo $renderer->render($page);
+echo $renderer->footer($questionnaire->course());
diff --git a/feedback.php b/feedback.php
index 2e0f07688..960d40857 100644
--- a/feedback.php
+++ b/feedback.php
@@ -26,151 +26,81 @@
*/
require_once("../../config.php");
-require_once($CFG->dirroot . '/mod/questionnaire/questionnaire.class.php');
+
+use mod_questionnaire\questionnaire;
+use mod_questionnaire\local\feedback\feedback_settings_controller;
+use mod_questionnaire\output\feedbackpage;
$id = required_param('id', PARAM_INT); // Course module ID.
$currentgroupid = optional_param('group', 0, PARAM_INT); // Groupid.
$action = optional_param('action', '', PARAM_ALPHA);
-if (! $cm = get_coursemodule_from_id('questionnaire', $id)) {
- throw new \moodle_exception('invalidcoursemodule', 'mod_questionnaire');
-}
-
-if (! $course = $DB->get_record("course", ["id" => $cm->course])) {
- throw new \moodle_exception('coursemisconf', 'mod_questionnaire');
-}
-
-if (! $questionnaire = $DB->get_record("questionnaire", ["id" => $cm->instance])) {
- throw new \moodle_exception('invalidcoursemodule', 'mod_questionnaire');
-}
+$questionnaire = questionnaire::from_cmid($id);
// Needed here for forced language courses.
-require_course_login($course, true, $cm);
-$context = context_module::instance($cm->id);
-
-$PAGE->set_url(new moodle_url($CFG->wwwroot . '/mod/questionnaire/feedback.php', ['id' => $id]));
-$PAGE->set_context($context);
-if (!isset($SESSION->questionnaire)) {
- $SESSION->questionnaire = new stdClass();
-}
-$questionnaire = new questionnaire($course, $cm, 0, $questionnaire);
+require_course_login($questionnaire->course(), true, $questionnaire->coursemodule());
-// Add renderer and page objects to the questionnaire object for display use.
-$questionnaire->add_renderer($PAGE->get_renderer('mod_questionnaire'));
-$questionnaire->add_page(new \mod_questionnaire\output\feedbackpage());
+$PAGE->set_url(new moodle_url('/mod/questionnaire/feedback.php', ['id' => $id]));
+$PAGE->set_context($questionnaire->context());
-$SESSION->questionnaire->current_tab = 'feedback';
+$renderer = $PAGE->get_renderer('mod_questionnaire');
+$page = new feedbackpage();
-if (!$questionnaire->capabilities->editquestions) {
+if (!$questionnaire->capabilities()->can_edit_questions()) {
throw new \moodle_exception('nopermissions', 'mod_questionnaire');
}
-$feedbackform = new \mod_questionnaire\feedback_form('feedback.php');
-$sdata = clone($questionnaire->survey);
-$sdata->sid = $questionnaire->survey->id;
-$sdata->id = $cm->id;
+$feedbackform = new \mod_questionnaire\local\feedback\feedback_form('feedback.php');
+$sdata = $questionnaire->survey()->to_stdclass();
+$sdata->sid = $questionnaire->surveyid();
+$sdata->id = $questionnaire->coursemodule()->id;
$draftideditor = file_get_submitted_draft_itemid('feedbacknotes');
$currentinfo = file_prepare_draft_area(
$draftideditor,
- $context->id,
+ $questionnaire->context()->id,
'mod_questionnaire',
'feedbacknotes',
$sdata->sid,
['subdirs' => true],
- $questionnaire->survey->feedbacknotes
+ $questionnaire->feedback()->notes()
);
$sdata->feedbacknotes = ['text' => $currentinfo, 'format' => FORMAT_HTML, 'itemid' => $draftideditor];
$feedbackform->set_data($sdata);
if ($feedbackform->is_cancelled()) {
- redirect(new moodle_url('/mod/questionnaire/view.php', ['id' => $questionnaire->cm->id]));
+ redirect(new moodle_url('/mod/questionnaire/view.php', ['id' => $questionnaire->coursemodule()->id]));
}
// Confirm that feedback can be used for this questionnaire...
-// Get all questions that are valid feedback questions.
-$validquestions = false;
-foreach ($questionnaire->questions as $question) {
- if ($question->valid_feedback()) {
- $validquestions = true;
- break;
- }
-}
+$validquestions = $questionnaire->feedback()->has_any_feedback_questions();
if ($settings = $feedbackform->get_data()) {
+ $controller = new feedback_settings_controller($questionnaire);
if (isset($settings->feedbacksettingsbutton1) || isset($settings->feedbacksettingsbutton2) || isset($settings->buttongroup)) {
- if (isset($settings->feedbackscores)) {
- $sdata->feedbackscores = $settings->feedbackscores;
- } else {
- $sdata->feedbackscores = 0;
- }
-
- if (isset($settings->feedbacknotes)) {
- $sdata->fbnotesitemid = $settings->feedbacknotes['itemid'];
- $sdata->fbnotesformat = $settings->feedbacknotes['format'];
- $sdata->feedbacknotes = $settings->feedbacknotes['text'];
- $sdata->feedbacknotes = file_save_draft_area_files(
- $sdata->fbnotesitemid,
- $context->id,
- 'mod_questionnaire',
- 'feedbacknotes',
- $sdata->id,
- ['subdirs' => true],
- $sdata->feedbacknotes
- );
- } else {
- $sdata->feedbacknotes = '';
- }
-
- if ($settings->feedbacksections > 0) {
- $sdata->feedbacksections = $settings->feedbacksections;
- $usergraph = get_config('questionnaire', 'usergraph');
- if ($usergraph) {
- if ($settings->feedbacksections == 1) {
- $sdata->chart_type = $settings->chart_type_global;
- } else if ($settings->feedbacksections == 2) {
- $sdata->chart_type = $settings->chart_type_two_sections;
- } else if ($settings->feedbacksections > 2) {
- $sdata->chart_type = $settings->chart_type_sections;
- }
- }
- } else {
- $sdata->feedbacksections = 0;
- }
- $sdata->courseid = $settings->courseid;
- if (!($sid = $questionnaire->survey_update($sdata))) {
- throw new \moodle_exception('couldnotcreatenewsurvey', 'mod_questionnaire');
- }
+ $controller->save($settings);
}
// Handle the edit feedback sections action.
if (isset($settings->buttongroup['feedbackeditbutton'])) {
- // Create a single section for Global Feedback if not existent.
- if (!($firstsection = $DB->get_field('questionnaire_fb_sections', 'MIN(section)', ['surveyid' => $questionnaire->sid]))) {
- $firstsection = 0;
- }
- if (($sdata->feedbacksections > 0) && ($firstsection == 0)) {
- if ($sdata->feedbacksections == 1) {
- $sectionlabel = get_string('feedbackglobal', 'questionnaire');
- } else {
- $sectionlabel = get_string('feedbackdefaultlabel', 'questionnaire');
- }
- $feedbacksection = mod_questionnaire\feedback\section::new_section($questionnaire->sid, $sectionlabel);
- }
- redirect(new moodle_url('/mod/questionnaire/fbsections.php', ['id' => $cm->id, 'section' => $firstsection]));
+ $firstsection = $questionnaire->feedback()->ensure_first_section();
+ redirect(new moodle_url(
+ '/mod/questionnaire/fbsections.php',
+ ['id' => $questionnaire->coursemodule()->id, 'section' => $firstsection]
+ ));
}
}
// Print the page header.
$PAGE->set_title(get_string('editingfeedback', 'questionnaire'));
-$PAGE->set_heading(format_string($course->fullname));
+$PAGE->set_heading(format_string($questionnaire->course()->fullname));
$PAGE->navbar->add(get_string('editingfeedback', 'questionnaire'));
-echo $questionnaire->renderer->header();
-require('tabs.php');
+echo $renderer->header();
+(new \mod_questionnaire\output\tabs($questionnaire, 'feedback'))->render($page);
if (!$validquestions) {
- $questionnaire->page->add_to_page('formarea', get_string('feedbackoptions_help', 'questionnaire'));
+ $page->add_to_page('formarea', get_string('feedbackoptions_help', 'questionnaire'));
} else {
- $questionnaire->page->add_to_page('formarea', $feedbackform->render());
+ $page->add_to_page('formarea', $feedbackform->render());
}
-echo $questionnaire->renderer->render($questionnaire->page);
-echo $questionnaire->renderer->footer($course);
+echo $renderer->render($page);
+echo $renderer->footer($questionnaire->course());
diff --git a/grade.php b/grade.php
index 930a282d3..20f0d3a0e 100644
--- a/grade.php
+++ b/grade.php
@@ -26,19 +26,15 @@
* Require config.php
*/
require_once("../../config.php");
-require_once($CFG->dirroot . '/mod/questionnaire/questionnaire.class.php');
$id = required_param('id', PARAM_INT);
$cm = get_coursemodule_from_id('questionnaire', $id, 0, false, MUST_EXIST);
-if (! $questionnaire = $DB->get_record("questionnaire", ["id" => $cm->instance])) {
- throw new \moodle_exception('invalidcoursemodule', 'mod_questionnaire');
-}
$course = $DB->get_record('course', ['id' => $cm->course], '*', MUST_EXIST);
require_login($course, false, $cm);
$PAGE->set_url('/mod/questionnaire/grade.php', ['id' => $cm->id]);
if (has_capability('mod/questionnaire:readallresponseanytime', context_module::instance($cm->id))) {
- redirect('report.php?instance=' . $questionnaire->id);
+ redirect('report.php?instance=' . $cm->instance);
} else {
redirect('view.php?id=' . $cm->id);
}
diff --git a/lib.php b/lib.php
index ae6807638..8bc6cfdbd 100644
--- a/lib.php
+++ b/lib.php
@@ -68,107 +68,13 @@ function questionnaire_get_extra_capabilities() {
return ['moodle/site:accessallgroups'];
}
-/**
- * Implementation of get_instance.
- * @param int $questionnaireid
- * @return false|mixed|stdClass
- */
-function questionnaire_get_instance($questionnaireid) {
- global $DB;
- return $DB->get_record('questionnaire', ['id' => $questionnaireid]);
-}
-
/**
* Implementation of add_instance.
* @param stdClass $questionnaire
* @return bool|int
*/
function questionnaire_add_instance($questionnaire) {
- // Given an object containing all the necessary data,
- // (defined by the form in mod.html) this function
- // will create a new instance and return the id number
- // of the new instance.
- global $DB, $CFG;
- require_once($CFG->dirroot . '/mod/questionnaire/questionnaire.class.php');
- require_once($CFG->dirroot . '/mod/questionnaire/locallib.php');
-
- $copyfiles = false;
-
- // Check the realm and set it to the survey if it's set.
- if (empty($questionnaire->sid)) {
- // Create a new survey.
- $course = get_course($questionnaire->course);
- $cm = new stdClass();
- $qobject = new questionnaire($course, $cm, 0, $questionnaire);
-
- if ($questionnaire->create == 'new-0') {
- $sdata = new stdClass();
- $sdata->name = $questionnaire->name;
- $sdata->realm = 'private';
- $sdata->title = $questionnaire->name;
- $sdata->subtitle = '';
- $sdata->info = '';
- $sdata->theme = ''; // Theme is deprecated.
- $sdata->thanks_page = '';
- $sdata->thank_head = '';
- $sdata->thank_body = '';
- $sdata->email = '';
- $sdata->feedbacknotes = '';
- $sdata->courseid = $course->id;
- if (!($sid = $qobject->survey_update($sdata))) {
- throw new \moodle_exception('couldnotcreatenewsurvey', 'mod_questionnaire');
- }
- } else {
- $copyid = explode('-', $questionnaire->create);
- $copyrealm = $copyid[0];
- $copyid = $copyid[1];
- if (empty($qobject->survey)) {
- $qobject->add_survey($copyid);
- $qobject->add_questions($copyid);
- }
- // New questionnaires created as "use public" should not create a new survey instance.
- if ($copyrealm == 'public') {
- $sid = $copyid;
- } else {
- $sid = $qobject->sid = $qobject->survey_copy($course->id);
- // All new questionnaires should be created as "private".
- // Even if they are *copies* of public or template questionnaires.
- $DB->set_field('questionnaire_survey', 'realm', 'private', ['id' => $sid]);
-
- // Need to copy any files from the old questionnaire instance to the new one.
- $questionnaire->copyid = $copyid;
- }
- // If the survey has dependency data, need to set the questionnaire to allow dependencies.
- if ($DB->count_records('questionnaire_dependency', ['surveyid' => $sid]) > 0) {
- $questionnaire->navigate = 1;
- }
- }
- $questionnaire->sid = $sid;
- }
-
- $questionnaire->timemodified = time();
-
- if ($questionnaire->resume == '1') {
- $questionnaire->resume = 1;
- } else {
- $questionnaire->resume = 0;
- }
-
- if (!$questionnaire->id = $DB->insert_record("questionnaire", $questionnaire)) {
- return false;
- }
-
- questionnaire_set_events($questionnaire);
-
- $completiontimeexpected = !empty($questionnaire->completionexpected) ? $questionnaire->completionexpected : null;
- \core_completion\api::update_completion_date_event(
- $questionnaire->coursemodule,
- 'questionnaire',
- $questionnaire->id,
- $completiontimeexpected
- );
-
- return $questionnaire->id;
+ return \mod_questionnaire\instance_admin::add_instance($questionnaire);
}
/**
@@ -178,37 +84,10 @@ function questionnaire_add_instance($questionnaire) {
* @return bool
*/
function questionnaire_update_instance($questionnaire) {
- global $DB, $CFG;
- require_once($CFG->dirroot . '/mod/questionnaire/locallib.php');
-
- // Check the realm and set it to the survey if its set.
- if (!empty($questionnaire->sid) && !empty($questionnaire->realm)) {
- $DB->set_field('questionnaire_survey', 'realm', $questionnaire->realm, ['id' => $questionnaire->sid]);
- }
-
- $questionnaire->timemodified = time();
+ // Grade item update is a Moodle gradebook API concern, kept in lib.php.
$questionnaire->id = $questionnaire->instance;
-
- if ($questionnaire->resume == '1') {
- $questionnaire->resume = 1;
- } else {
- $questionnaire->resume = 0;
- }
-
- // Get existing grade item.
questionnaire_grade_item_update($questionnaire);
-
- questionnaire_set_events($questionnaire);
-
- $completiontimeexpected = !empty($questionnaire->completionexpected) ? $questionnaire->completionexpected : null;
- \core_completion\api::update_completion_date_event(
- $questionnaire->coursemodule,
- 'questionnaire',
- $questionnaire->id,
- $completiontimeexpected
- );
-
- return $DB->update_record("questionnaire", $questionnaire);
+ return \mod_questionnaire\instance_admin::update_instance($questionnaire);
}
/**
@@ -217,34 +96,7 @@ function questionnaire_update_instance($questionnaire) {
* @return bool
*/
function questionnaire_delete_instance($id) {
- global $DB, $CFG;
- require_once($CFG->dirroot . '/mod/questionnaire/locallib.php');
-
- 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();
- }
- }
-
- if (! $DB->delete_records('questionnaire', ['id' => $questionnaire->id])) {
- $result = false;
- }
-
- if ($survey = $DB->get_record('questionnaire_survey', ['id' => $questionnaire->sid])) {
- // If this survey is owned by this course, delete all of the survey records and responses.
- if ($survey->courseid == $questionnaire->course) {
- $result = $result && questionnaire_delete_survey($questionnaire->sid, $questionnaire->id);
- }
- }
-
- return $result;
+ return \mod_questionnaire\instance_admin::delete_instance($id);
}
/**
@@ -315,11 +167,13 @@ function questionnaire_get_coursemodule_info($coursemodule) {
* @return stdClass
*/
function questionnaire_user_outline($course, $user, $mod, $questionnaire) {
- global $CFG;
- require_once($CFG->dirroot . '/mod/questionnaire/locallib.php');
-
$result = new stdClass();
- if ($responses = questionnaire_get_user_responses($questionnaire->id, $user->id, true)) {
+ $responses = \mod_questionnaire\local\response\questionnaire_responses::get_user_responses_for_instance(
+ ($questionnaire instanceof \mod_questionnaire\questionnaire) ? $questionnaire->id() : $questionnaire->id,
+ $user->id,
+ true
+ );
+ if ($responses) {
$n = count($responses);
if ($n == 1) {
$result->info = $n . ' ' . get_string("response", "questionnaire");
@@ -345,10 +199,12 @@ function questionnaire_user_outline($course, $user, $mod, $questionnaire) {
* @return bool
*/
function questionnaire_user_complete($course, $user, $mod, $questionnaire) {
- global $CFG;
- require_once($CFG->dirroot . '/mod/questionnaire/locallib.php');
-
- if ($responses = questionnaire_get_user_responses($questionnaire->id, $user->id, false)) {
+ $responses = \mod_questionnaire\local\response\questionnaire_responses::get_user_responses_for_instance(
+ ($questionnaire instanceof \mod_questionnaire\questionnaire) ? $questionnaire->id() : $questionnaire->id,
+ $user->id,
+ false
+ );
+ if ($responses) {
foreach ($responses as $response) {
if ($response->complete == 'y') {
echo get_string('submitted', 'questionnaire') . ' ' . userdate($response->submitted) . ' ';
@@ -395,18 +251,7 @@ function questionnaire_grades($questionnaireid) {
* @return array array of grades, false if none
*/
function questionnaire_get_user_grades($questionnaire, $userid = 0) {
- global $DB;
- $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 = $questionnaire->id AND r.complete = 'y' $usersql";
- return $DB->get_records_sql($sql, $params) ?? [];
+ return \mod_questionnaire\gradebook::get_user_grades($questionnaire, $userid);
}
/**
@@ -417,47 +262,7 @@ function questionnaire_get_user_grades($questionnaire, $userid = 0) {
* @param bool $nullifnone
*/
function questionnaire_update_grades($questionnaire = null, $userid = 0, $nullifnone = true) {
- global $CFG, $DB;
-
- if (!function_exists('grade_update')) { // Workaround for buggy PHP versions.
- require_once($CFG->libdir . '/gradelib.php');
- }
-
- if ($questionnaire != null) {
- if ($graderecs = questionnaire_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;
- }
- }
- questionnaire_grade_item_update($questionnaire, $grades);
- } else {
- questionnaire_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) {
- questionnaire_update_grades($questionnaire);
- } else {
- questionnaire_grade_item_update($questionnaire);
- }
- }
- $rs->close();
- }
- }
+ \mod_questionnaire\gradebook::update_grades($questionnaire, $userid, $nullifnone);
}
/**
@@ -468,50 +273,7 @@ function questionnaire_update_grades($questionnaire = null, $userid = 0, $nullif
* @return int 0 if ok, error code otherwise
*/
function questionnaire_grade_item_update($questionnaire, $grades = null) {
- global $CFG;
- if (!function_exists('grade_update')) { // Workaround for buggy PHP versions.
- require_once($CFG->libdir . '/gradelib.php');
- }
-
- if (!isset($questionnaire->courseid)) {
- $questionnaire->courseid = $questionnaire->course;
- }
-
- if ($questionnaire->cmidnumber != '') {
- $params = ['itemname' => $questionnaire->name, 'idnumber' => $questionnaire->cmidnumber];
- } else {
- $params = ['itemname' => $questionnaire->name];
- }
-
- if ($questionnaire->grade > 0) {
- $params['gradetype'] = GRADE_TYPE_VALUE;
- $params['grademax'] = $questionnaire->grade;
- $params['grademin'] = 0;
- } else if ($questionnaire->grade < 0) {
- $params['gradetype'] = GRADE_TYPE_SCALE;
- $params['scaleid'] = -$questionnaire->grade;
- } else if ($questionnaire->grade == 0) { // No Grade..be sure to delete the grade item if it exists.
- $grades = null;
- $params = ['deleted' => 1];
- } else {
- $params = null; // Allow text comments only.
- }
-
- if ($grades === 'reset') {
- $params['reset'] = true;
- $grades = null;
- }
-
- return grade_update(
- 'mod/questionnaire',
- $questionnaire->courseid,
- 'mod',
- 'questionnaire',
- $questionnaire->id,
- 0,
- $grades,
- $params
- );
+ return \mod_questionnaire\gradebook::grade_item_update($questionnaire, $grades);
}
/**
@@ -613,336 +375,9 @@ function questionnaire_pluginfile($course, $cm, $context, $filearea, $args, $for
*
* @param settings_navigation $settings The settings navigation object
* @param navigation_node $questionnairenode The node to add module settings to
- *
- * $settings is unused, but API requires it. Suppress PHPMD warning.
*/
function questionnaire_extend_settings_navigation(settings_navigation $settings, navigation_node $questionnairenode) {
- global $DB, $USER, $CFG;
-
- $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.
-
- require_once($CFG->dirroot . '/mod/questionnaire/questionnaire.class.php');
-
- $cm = $settings->get_page()->cm;
- $context = $cm->context;
- $cmid = $cm->id;
- $course = $settings->get_page()->course;
-
- if (! $questionnaire = $DB->get_record("questionnaire", ["id" => $cm->instance])) {
- throw new \moodle_exception('invalidcoursemodule', 'mod_questionnaire');
- }
-
- $courseid = $course->id;
- $questionnaire = new questionnaire($course, $cm, 0, $questionnaire);
-
- if ($owner = $DB->get_field('questionnaire_survey', 'courseid', ['id' => $questionnaire->sid])) {
- $owner = (trim($owner) == trim($courseid));
- } else {
- $owner = true;
- }
-
- // 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($questionnaire->cm);
- 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 ($questionnaire->user_can_take($USER->id)) {
- $url = '/mod/questionnaire/complete.php';
- if ($questionnaire->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 = $questionnaire->count_submissions($USER->id);
-
- if ($questionnaire->capabilities->readownresponses && ($usernumresp > 0)) {
- $url = '/mod/questionnaire/myreport.php';
-
- if ($usernumresp > 1) {
- $urlargs = [
- 'instance' => $questionnaire->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' => $questionnaire->id,
- 'userid' => $USER->id,
- 'byresponse' => 0,
- 'action' => 'summary',
- 'group' => $currentgroupid,
- ];
- $myreportnode->add(get_string('summary', 'questionnaire'), new moodle_url($url, $urlargs));
-
- $urlargs = [
- 'instance' => $questionnaire->id,
- 'userid' => $USER->id,
- 'byresponse' => 1,
- 'action' => 'vresp',
- 'group' => $currentgroupid,
- ];
- $byresponsenode = $myreportnode->add(
- get_string('viewindividualresponse', 'questionnaire'),
- new moodle_url($url, $urlargs)
- );
-
- $urlargs = [
- 'instance' => $questionnaire->id,
- 'userid' => $USER->id,
- 'byresponse' => 0,
- 'action' => 'vall',
- 'group' => $currentgroupid,
- ];
- $myreportnode->add(get_string('myresponses', 'questionnaire'), new moodle_url($url, $urlargs));
- if ($questionnaire->capabilities->downloadresponses) {
- $urlargs = [
- 'instance' => $questionnaire->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' => $questionnaire->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 ($questionnaire->can_view_all_responses($usernumresp)) {
- $url = '/mod/questionnaire/report.php';
- $node = navigation_node::create(
- get_string('viewallresponses', 'questionnaire'),
- new moodle_url(
- $url,
- ['instance' => $questionnaire->id, 'action' => 'vall']
- ),
- navigation_node::TYPE_SETTING,
- null,
- 'vall'
- );
- $reportnode = $questionnairenode->add_node($node, $beforekey);
-
- if ($questionnaire->capabilities->viewsingleresponse) {
- $summarynode = $reportnode->add(
- get_string('summary', 'questionnaire'),
- new moodle_url(
- '/mod/questionnaire/report.php',
- ['instance' => $questionnaire->id, 'action' => 'vall']
- )
- );
- } else {
- $summarynode = $reportnode;
- }
- $summarynode->add(
- get_string('order_default', 'questionnaire'),
- new moodle_url(
- '/mod/questionnaire/report.php',
- ['instance' => $questionnaire->id, 'action' => 'vall', 'group' => $currentgroupid]
- )
- );
- $summarynode->add(
- get_string('order_ascending', 'questionnaire'),
- new moodle_url(
- '/mod/questionnaire/report.php',
- ['instance' => $questionnaire->id, 'action' => 'vallasort', 'group' => $currentgroupid]
- )
- );
- $summarynode->add(
- get_string('order_descending', 'questionnaire'),
- new moodle_url(
- '/mod/questionnaire/report.php',
- ['instance' => $questionnaire->id, 'action' => 'vallarsort', 'group' => $currentgroupid]
- )
- );
-
- if ($questionnaire->capabilities->deleteresponses) {
- $summarynode->add(
- get_string('deleteallresponses', 'questionnaire'),
- new moodle_url(
- '/mod/questionnaire/report.php',
- ['instance' => $questionnaire->id, 'action' => 'delallresp', 'group' => $currentgroupid]
- )
- );
- }
-
- if ($questionnaire->capabilities->downloadresponses) {
- $summarynode->add(
- get_string('downloadtextformat', 'questionnaire'),
- new moodle_url(
- '/mod/questionnaire/report.php',
- ['instance' => $questionnaire->id, 'action' => 'dwnpg', 'group' => $currentgroupid]
- )
- );
- }
- if ($questionnaire->capabilities->viewsingleresponse) {
- $byresponsenode = $reportnode->add(
- get_string('viewbyresponse', 'questionnaire'),
- new moodle_url(
- '/mod/questionnaire/report.php',
- ['instance' => $questionnaire->id, 'action' => 'vresp', 'byresponse' => 1, 'group' => $currentgroupid]
- )
- );
-
- $byresponsenode->add(
- get_string('view', 'questionnaire'),
- new moodle_url(
- '/mod/questionnaire/report.php',
- ['instance' => $questionnaire->id, 'action' => 'vresp', 'byresponse' => 1, 'group' => $currentgroupid]
- )
- );
-
- if ($individualresponse) {
- $byresponsenode->add(
- get_string('deleteresp', 'questionnaire'),
- new moodle_url(
- '/mod/questionnaire/report.php',
- [
- 'instance' => $questionnaire->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 ($questionnaire->capabilities->viewsingleresponse && ($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);
- }
+ \mod_questionnaire\questionnaire::from_cm($settings->get_page()->cm)->extend_settings_navigation($settings, $questionnairenode);
}
// Any other questionnaire functions go here. Each of them must have a name that
@@ -984,181 +419,7 @@ function questionnaire_get_recent_mod_activity(
$userid = 0,
$groupid = 0
) {
-
- global $CFG, $COURSE, $USER, $DB;
- require_once($CFG->dirroot . '/mod/questionnaire/locallib.php');
- require_once($CFG->dirroot . '/mod/questionnaire/questionnaire.class.php');
-
- if ($COURSE->id == $courseid) {
- $course = $COURSE;
- } else {
- $course = $DB->get_record('course', ['id' => $courseid]);
- }
-
- $modinfo = get_fast_modinfo($course);
-
- $cm = $modinfo->cms[$cmid];
- $questionnaire = $DB->get_record('questionnaire', ['id' => $cm->instance]);
- $questionnaire = new questionnaire($course, $cm, 0, $questionnaire);
-
- $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.
- $originalquestionnaire = $DB->get_record(
- 'questionnaire',
- ['sid' => $questionnaire->survey->id, 'course' => $questionnaire->survey->courseid]
- );
- $cmoriginal = get_coursemodule_from_instance(
- "questionnaire",
- $originalquestionnaire->id,
- $questionnaire->survey->courseid
- );
- $contextoriginal = context_course::instance($questionnaire->survey->courseid, 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 $activities;
- }
- }
- }
-
- 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;
- }
+ \mod_questionnaire\questionnaire::get_recent_mod_activity($activities, $index, $timestart, $courseid, $cmid, $userid, $groupid);
}
/**
@@ -1173,176 +434,9 @@ function questionnaire_get_recent_mod_activity(
* $details and $modenames are unused, but API requires them. Suppress PHPMD warning.
*/
function questionnaire_print_recent_mod_activity($activity, $courseid, $detail, $modnames) {
- 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');
-
- return;
-}
-
-/**
- * Prints questionnaire summaries on 'My home' page
- *
- * Prints questionnaire name, due date and attempt information on
- * questionnaires that have a deadline that has not already passed
- * and it is available for taking.
- *
- * @param array $courses An array of course objects to get questionnaire instances from
- * @param array $htmlarray Store overview output array( course ID => 'questionnaire' => HTML output )
- * @return void
- */
-function questionnaire_print_overview($courses, &$htmlarray) {
- global $USER, $CFG, $DB, $OUTPUT;
-
- require_once($CFG->dirroot . '/mod/questionnaire/locallib.php');
-
- if (!$questionnaires = get_all_instances_in_courses('questionnaire', $courses)) {
- return;
- }
-
- // Get Necessary Strings.
- $strquestionnaire = get_string('modulename', 'questionnaire');
- $strnotattempted = get_string('noattempts', 'questionnaire');
- $strattempted = get_string('attempted', 'questionnaire');
- $strsavedbutnotsubmitted = get_string('savedbutnotsubmitted', 'questionnaire');
-
- $now = time();
- foreach ($questionnaires as $questionnaire) {
- // The questionnaire has a deadline.
- if (
- ($questionnaire->closedate != 0) &&
- // And it is before the deadline has been met.
- ($questionnaire->closedate >= $now) &&
- // And the questionnaire is available.
- (($questionnaire->opendate == 0) || ($questionnaire->opendate <= $now))
- ) {
- if (!$questionnaire->visible) {
- $class = ' class="dimmed"';
- } else {
- $class = '';
- }
- $str = $OUTPUT->box(
- "$strquestionnaire:
- wwwroot/mod/questionnaire/view.php?id=$questionnaire->coursemodule\">" .
- format_string($questionnaire->name) . ' ',
- 'name'
- );
-
- // Deadline.
- $str .= $OUTPUT->box(get_string('closeson', 'questionnaire', userdate($questionnaire->closedate)), 'info');
- $attempts = $DB->get_records(
- 'questionnaire_response',
- ['questionnaireid' => $questionnaire->id, 'userid' => $USER->id, 'complete' => 'y']
- );
- $nbattempts = count($attempts);
-
- // Do not display a questionnaire as due if it can only be sumbitted once and it has already been submitted!
- if ($nbattempts != 0 && $questionnaire->qtype == QUESTIONNAIREONCE) {
- continue;
- }
-
- // Attempt information.
- if (has_capability('mod/questionnaire:manage', context_module::instance($questionnaire->coursemodule))) {
- // Number of user attempts.
- $attempts = $DB->count_records(
- 'questionnaire_response',
- ['questionnaireid' => $questionnaire->id, 'complete' => 'y']
- );
- $str .= $OUTPUT->box(get_string('numattemptsmade', 'questionnaire', $attempts), 'info');
- } else {
- if ($responses = questionnaire_get_user_responses($questionnaire->id, $USER->id, false)) {
- foreach ($responses as $response) {
- if ($response->complete == 'y') {
- $str .= $OUTPUT->box($strattempted, 'info');
- break;
- } else {
- $str .= $OUTPUT->box($strsavedbutnotsubmitted, 'info');
- }
- }
- } else {
- $str .= $OUTPUT->box($strnotattempted, 'info');
- }
- }
- $str = $OUTPUT->box($str, 'questionnaire overview');
-
- if (empty($htmlarray[$questionnaire->course]['questionnaire'])) {
- $htmlarray[$questionnaire->course]['questionnaire'] = $str;
- } else {
- $htmlarray[$questionnaire->course]['questionnaire'] .= $str;
- }
- }
- }
+ \mod_questionnaire\questionnaire::print_recent_mod_activity($activity, $courseid, $detail, $modnames);
}
-
/**
* Implementation of the function for printing the form elements that control
* whether the course reset functionality affects the questionnaire.
@@ -1377,79 +471,7 @@ function questionnaire_reset_course_form_defaults($course) {
* @return array status array
*/
function questionnaire_reset_userdata($data) {
- global $CFG, $DB;
- require_once($CFG->libdir . '/questionlib.php');
- require_once($CFG->dirroot . '/mod/questionnaire/locallib.php');
-
- $componentstr = get_string('modulenameplural', 'questionnaire');
- $status = [];
-
- if (!empty($data->reset_questionnaire)) {
- $surveys = questionnaire_get_survey_list($data->courseid, '');
-
- // Delete responses.
- foreach ($surveys as $survey) {
- // Get all responses for this questionnaire.
- $sql = "SELECT qr.id, qr.questionnaireid, qr.submitted, qr.userid, q.sid
- FROM {questionnaire} q
- INNER JOIN {questionnaire_response} qr ON q.id = qr.questionnaireid
- WHERE q.sid = ?
- ORDER BY qr.id";
- $resps = $DB->get_records_sql($sql, [$survey->id]);
- if (!empty($resps)) {
- $questionnaire = $DB->get_record("questionnaire", ["sid" => $survey->id, "course" => $survey->courseid]);
- $questionnaire->course = $DB->get_record("course", ["id" => $questionnaire->course]);
- foreach ($resps as $response) {
- questionnaire_delete_response($response, $questionnaire);
- }
- }
- // Remove this questionnaire's grades (and feedback) from gradebook (if any).
- $select = "itemmodule = 'questionnaire' AND iteminstance = " . $survey->qid;
- $fields = 'id';
- if ($itemid = $DB->get_record_select('grade_items', $select, null, $fields)) {
- $itemid = $itemid->id;
- $DB->delete_records_select('grade_grades', 'itemid = ' . $itemid);
- }
- }
- $status[] = [
- 'component' => $componentstr,
- 'item' => get_string('deletedallresp', 'questionnaire'),
- 'error' => false,
- ];
-
- $status[] = [
- 'component' => $componentstr,
- 'item' => get_string('gradesdeleted', 'questionnaire'),
- 'error' => false,
- ];
- }
- return $status;
-}
-
-/**
- * Obtains the automatic completion state for this questionnaire based on the condition
- * in questionnaire settings.
- *
- * @param object $cm Course-module
- * @param int $userid User ID
- * @param bool $type Type of comparison (or/and; can be used as return value if no conditions)
- * @return bool True if completed, false if not, $type if conditions not set.
- *
- */
-function questionnaire_get_completion_state($cm, $userid, $type) {
- global $DB;
-
- // Get questionnaire details.
- $questionnaire = $DB->get_record('questionnaire', ['id' => $cm->instance], '*', MUST_EXIST);
-
- // If completion option is enabled, evaluate it and return true/false.
- if ($questionnaire->completionsubmit) {
- $params = ['userid' => $userid, 'questionnaireid' => $questionnaire->id, 'complete' => 'y'];
- return $DB->record_exists('questionnaire_response', $params);
- } else {
- // Completion option is not enabled so just return $type.
- return $type;
- }
+ return \mod_questionnaire\instance_admin::reset_userdata($data);
}
/**
@@ -1493,45 +515,5 @@ function mod_questionnaire_core_calendar_provide_event_action(
* @throws coding_exception
*/
function mod_questionnaire_coursemodule_edit_post_actions($data, $course) {
- global $DB;
-
- if (!empty($data->copyid)) {
- $cm = (object)['id' => $data->coursemodule];
- $questionnaire = new questionnaire($course, $cm, 0, $data);
- $oldquestionnaireid = $DB->get_field('questionnaire', 'id', ['sid' => $data->copyid]);
- $oldcm = get_coursemodule_from_instance('questionnaire', $oldquestionnaireid);
- $oldquestionnaire = new questionnaire($course, $oldcm, $oldquestionnaireid, null);
- $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;
+ return \mod_questionnaire\questionnaire::coursemodule_edit_post_actions($data, $course);
}
diff --git a/locallib.php b/locallib.php
deleted file mode 100644
index 6d1933e7a..000000000
--- a/locallib.php
+++ /dev/null
@@ -1,1199 +0,0 @@
-.
-
-/**
- * Updates the contents of the survey with the provided data. If no data is provided, it checks for posted data.
- *
- * This library replaces the phpESP application with Moodle specific code. It will eventually
- * replace all of the phpESP application, removing the dependency on that.
- *
- * @package mod_questionnaire
- * @copyright 2016 Mike Churchward (mike.churchward@poetgroup.org)
- * @author Mike Churchward
- * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
- *
- */
-
-defined('MOODLE_INTERNAL') || die();
-
-require_once($CFG->dirroot . '/calendar/lib.php');
-// Constants.
-
-define('QUESTIONNAIREUNLIMITED', 0);
-define('QUESTIONNAIREONCE', 1);
-define('QUESTIONNAIREDAILY', 2);
-define('QUESTIONNAIREWEEKLY', 3);
-define('QUESTIONNAIREMONTHLY', 4);
-
-define('QUESTIONNAIRE_STUDENTVIEWRESPONSES_NEVER', 0);
-define('QUESTIONNAIRE_STUDENTVIEWRESPONSES_WHENANSWERED', 1);
-define('QUESTIONNAIRE_STUDENTVIEWRESPONSES_WHENCLOSED', 2);
-define('QUESTIONNAIRE_STUDENTVIEWRESPONSES_ALWAYS', 3);
-
-define('QUESTIONNAIRE_MAX_EVENT_LENGTH', 5 * 24 * 60 * 60); // 5 days maximum.
-
-define('QUESTIONNAIRE_DEFAULT_PAGE_COUNT', 20);
-
-define('QUESTIONNAIRE_CONFIRM_DELETE_PERMANENTLY', 'confirmdelpermanentlyq');
-define('QUESTIONNAIRE_RESTORE_PARAM', 'restoreq');
-
-global $questionnairetypes;
-$questionnairetypes = [
- QUESTIONNAIREUNLIMITED => get_string('qtypeunlimited', 'questionnaire'),
- QUESTIONNAIREONCE => get_string('qtypeonce', 'questionnaire'),
- QUESTIONNAIREDAILY => get_string('qtypedaily', 'questionnaire'),
- QUESTIONNAIREWEEKLY => get_string('qtypeweekly', 'questionnaire'),
- QUESTIONNAIREMONTHLY => get_string('qtypemonthly', 'questionnaire'),
-];
-
-global $questionnairerespondents;
-$questionnairerespondents = [
- 'fullname' => get_string('respondenttypefullname', 'questionnaire'),
- 'anonymous' => get_string('respondenttypeanonymous', 'questionnaire'),
-];
-
-global $questionnairerealms;
-$questionnairerealms = [
- 'private' => get_string('private', 'questionnaire'),
- 'public' => get_string('public', 'questionnaire'),
- 'template' => get_string('template', 'questionnaire'),
-];
-
-global $questionnaireresponseviewers;
-$questionnaireresponseviewers = [
- QUESTIONNAIRE_STUDENTVIEWRESPONSES_WHENANSWERED => get_string('responseviewstudentswhenanswered', 'questionnaire'),
- QUESTIONNAIRE_STUDENTVIEWRESPONSES_WHENCLOSED => get_string('responseviewstudentswhenclosed', 'questionnaire'),
- QUESTIONNAIRE_STUDENTVIEWRESPONSES_ALWAYS => get_string('responseviewstudentsalways', 'questionnaire'),
- QUESTIONNAIRE_STUDENTVIEWRESPONSES_NEVER => get_string('responseviewstudentsnever', 'questionnaire'),
-];
-
-global $autonumbering;
-$autonumbering = [
- 0 => get_string('autonumberno', 'questionnaire'),
- 1 => get_string('autonumberquestions', 'questionnaire'),
- 2 => get_string('autonumberpages', 'questionnaire'),
- 3 => get_string('autonumberpagesandquestions', 'questionnaire'),
-];
-
-/**
- * Return the choice values for the content.
- * @param string $content
- * @return stdClass
- */
-function questionnaire_choice_values($content) {
-
- // If we run the content through format_text first, any filters we want to use (e.g. multilanguage) should work.
- // examines the content of a possible answer from radio button, check boxes or rate question
- // returns ->text to be displayed, ->image if present, ->modname name of modality, image ->title.
- $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;
- // DEV JR from version 2.5, a double colon :: must be used here instead of the equal sign.
- if ($pos = strpos($content, '::')) {
- $contents->text = substr($content, $pos + 2);
- $contents->modname = substr($content, 0, $pos);
- }
- return $contents;
-}
-
-/**
- * Get the information about the standard questionnaire JavaScript module.
- * @return array a standard jsmodule structure.
- */
-function questionnaire_get_js_module() {
- 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'],
- ],
- ];
-}
-
-/**
- * Get all the questionnaire responses for a user.
- * @param int $questionnaireid
- * @param int $userid
- * @param bool $complete
- * @return array
- */
-function questionnaire_get_user_responses($questionnaireid, $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 ", [$questionnaireid, $userid]) ?? [];
-}
-
-/**
- * get the capabilities for the questionnaire
- * @param int $cmid
- * @return object the available capabilities from current user
- */
-function questionnaire_load_capabilities($cmid) {
- static $cb;
-
- if (isset($cb)) {
- return $cb;
- }
-
- $context = questionnaire_get_context($cmid);
-
- $cb = new stdClass();
- $cb->view = has_capability('mod/questionnaire:view', $context);
- $cb->submit = has_capability('mod/questionnaire:submit', $context);
- $cb->viewsingleresponse = has_capability('mod/questionnaire:viewsingleresponse', $context);
- $cb->submissionnotification = has_capability('mod/questionnaire:submissionnotification', $context);
- $cb->downloadresponses = has_capability('mod/questionnaire:downloadresponses', $context);
- $cb->deleteresponses = has_capability('mod/questionnaire:deleteresponses', $context);
- $cb->manage = has_capability('mod/questionnaire:manage', $context);
- $cb->editquestions = has_capability('mod/questionnaire:editquestions', $context);
- $cb->createtemplates = has_capability('mod/questionnaire:createtemplates', $context);
- $cb->createpublic = has_capability('mod/questionnaire:createpublic', $context);
- $cb->readownresponses = has_capability('mod/questionnaire:readownresponses', $context);
- $cb->readallresponses = has_capability('mod/questionnaire:readallresponses', $context);
- $cb->readallresponseanytime = has_capability('mod/questionnaire:readallresponseanytime', $context);
- $cb->printblank = has_capability('mod/questionnaire:printblank', $context);
- $cb->preview = has_capability('mod/questionnaire:preview', $context);
-
- $cb->viewhiddenactivities = has_capability('moodle/course:viewhiddenactivities', $context, null, false);
-
- return $cb;
-}
-
-/**
- * returns the context-id related to the given coursemodule-id
- * @param int $cmid the coursemodule-id
- * @return object $context
- */
-function questionnaire_get_context($cmid) {
- static $context;
-
- if (isset($context)) {
- return $context;
- }
-
- if (!$context = context_module::instance($cmid)) {
- throw new \moodle_exception('badcontext', 'mod_questionnaire');
- }
- return $context;
-}
-
-/**
- * This function *really* shouldn't be needed, but since sometimes we can end up with
- * orphaned surveys, this will clean them up.
- * @return bool
- * @throws dml_exception
- */
-function questionnaire_cleanup() {
- global $DB;
-
- // Find surveys that don't have questionnaires associated with them.
- $sql = 'SELECT qs.* FROM {questionnaire_survey} qs ' .
- 'LEFT JOIN {questionnaire} q ON q.sid = qs.id ' .
- 'WHERE q.sid IS NULL';
-
- if ($surveys = $DB->get_records_sql($sql)) {
- foreach ($surveys as $survey) {
- questionnaire_delete_survey($survey->id, 0);
- }
- }
- // Find deleted questions and remove them from database (with their associated choices, etc.).
- return true;
-}
-
-/**
- * Delete the survey.
- * @param int $sid
- * @param int $questionnaireid
- * @return bool
- */
-function questionnaire_delete_survey($sid, $questionnaireid) {
- global $DB;
- $status = true;
- // Delete all survey attempts and responses.
- if ($responses = $DB->get_records('questionnaire_response', ['questionnaireid' => $questionnaireid], 'id')) {
- foreach ($responses as $response) {
- $status = $status && questionnaire_delete_response($response);
- }
- }
-
- // There really shouldn't be any more, but just to make sure...
- $DB->delete_records('questionnaire_response', ['questionnaireid' => $questionnaireid]);
-
- // Delete all question data for the survey.
- if ($questions = $DB->get_records('questionnaire_question', ['surveyid' => $sid], 'id')) {
- foreach ($questions as $question) {
- $DB->delete_records('questionnaire_quest_choice', ['question_id' => $question->id]);
- questionnaire_delete_dependencies($question->id);
- }
- $status = $status && $DB->delete_records('questionnaire_question', ['surveyid' => $sid]);
- // Just to make sure.
- $status = $status && $DB->delete_records('questionnaire_dependency', ['surveyid' => $sid]);
- }
-
- // Delete all feedback sections and feedback messages for the survey.
- if ($fbsections = $DB->get_records('questionnaire_fb_sections', ['surveyid' => $sid], 'id')) {
- foreach ($fbsections as $fbsection) {
- $DB->delete_records('questionnaire_feedback', ['sectionid' => $fbsection->id]);
- }
- $status = $status && $DB->delete_records('questionnaire_fb_sections', ['surveyid' => $sid]);
- }
-
- $status = $status && $DB->delete_records('questionnaire_survey', ['id' => $sid]);
-
- return $status;
-}
-
-/**
- * Delete permanently questions and data reference.
- *
- * @param int $qid question id.
- * @param int $sid survey question id.
- * @return void
- */
-function questionnaire_delete_permanently_questions($qid, $sid) {
- global $DB;
- $select = 'id = :id AND surveyid = :sid AND deleted IS NOT NULL';
- $DB->delete_records_select('questionnaire_question', $select, ['id' => $qid, 'sid' => $sid]);
- questionnaire_delete_responses($qid);
- questionnaire_delete_dependencies($qid);
-}
-
-/**
- * Log question deleted event.
- *
- * @param int $cmid of module.
- * @param string $questiontype of question.
- * @param int $courseid of question.
- * @return void.
- */
-function questionnaire_observe_event_delete($cmid, $questiontype, $courseid) {
- $context = context_module::instance($cmid);
- $params = [
- 'context' => $context,
- 'courseid' => $courseid,
- 'other' => ['questiontype' => $questiontype],
- ];
- $event = \mod_questionnaire\event\question_deleted::create($params);
- $event->trigger();
-}
-
-/**
- * Restore deleted questions.
- *
- * @param int $qid question id.
- * @param int $sid survey id.
- * @return void
- */
-function questionnaire_restore_deleted_question($qid, $sid) {
- global $DB;
- // Get current deleted question and last position.
- $sql = "SELECT *, (
- SELECT position + 1
- FROM {questionnaire_question}
- WHERE surveyid = ?
- AND deleted IS NULL
- ORDER BY position DESC
- LIMIT 1 ) as lastposition
- FROM {questionnaire_question}
- WHERE id = ?
- AND surveyid = ?
- AND deleted IS NOT NULL";
- $question = $DB->get_record_sql($sql, [$sid, $qid, $sid]);
- if ($question) {
- // Update question using Moodle API. Only 'id' is needed for update_record.
- $question->deleted = null;
- $question->position = $question->lastposition ?? 1;
- $DB->update_record('questionnaire_question', $question);
- }
-}
-
-/**
- * Get range of time permanently in setup cron task.
- */
-function questionnaire_get_range_time_permanently() {
- return get_config('questionnaire_questiondeletion', 'duration');
-}
-
-/**
- * Delete the response.
- * @param stdClass $response
- * @param string $questionnaire
- * @return bool
- */
-function questionnaire_delete_response($response, $questionnaire = '') {
- global $DB;
- $status = true;
- $cm = '';
- $rid = $response->id;
- // The questionnaire_delete_survey function does not send the questionnaire array.
- if ($questionnaire != '') {
- $cm = get_coursemodule_from_instance(
- "questionnaire",
- $questionnaire->id,
- $questionnaire->course->id
- );
- }
-
- // Delete all of the response data for a response.
- $DB->delete_records('questionnaire_response_bool', ['response_id' => $rid]);
- $DB->delete_records('questionnaire_response_date', ['response_id' => $rid]);
- $DB->delete_records('questionnaire_resp_multiple', ['response_id' => $rid]);
- $DB->delete_records('questionnaire_response_other', ['response_id' => $rid]);
- $DB->delete_records('questionnaire_response_rank', ['response_id' => $rid]);
- $DB->delete_records('questionnaire_resp_single', ['response_id' => $rid]);
- $DB->delete_records('questionnaire_response_text', ['response_id' => $rid]);
- $DB->delete_records('questionnaire_response_file', ['response_id' => $rid]);
-
- $status = $status && $DB->delete_records('questionnaire_response', ['id' => $rid]);
-
- if ($status && $cm) {
- // Update completion state if necessary.
- $completion = new completion_info($questionnaire->course);
- if ($completion->is_enabled($cm) == COMPLETION_TRACKING_AUTOMATIC && $questionnaire->completionsubmit) {
- $completion->update_state($cm, COMPLETION_INCOMPLETE, $response->userid);
- }
- }
-
- return $status;
-}
-
-/**
- * Delete all responses for the questionnaire.
- * @param int $qid
- * @return bool
- */
-function questionnaire_delete_responses($qid) {
- global $DB;
-
- // Delete all of the response data for a question.
- $DB->delete_records('questionnaire_response_bool', ['question_id' => $qid]);
- $DB->delete_records('questionnaire_response_date', ['question_id' => $qid]);
- $DB->delete_records('questionnaire_resp_multiple', ['question_id' => $qid]);
- $DB->delete_records('questionnaire_response_other', ['question_id' => $qid]);
- $DB->delete_records('questionnaire_response_rank', ['question_id' => $qid]);
- $DB->delete_records('questionnaire_resp_single', ['question_id' => $qid]);
- $DB->delete_records('questionnaire_response_text', ['question_id' => $qid]);
- $DB->delete_records('questionnaire_response_file', ['question_id' => $qid]);
-
- return true;
-}
-
-/**
- * Delete all dependencies for the questionnaire.
- * @param int $qid
- * @return bool
- */
-function questionnaire_delete_dependencies($qid) {
- global $DB;
-
- // Delete all dependencies for this question.
- $DB->delete_records('questionnaire_dependency', ['questionid' => $qid]);
- $DB->delete_records('questionnaire_dependency', ['dependquestionid' => $qid]);
-
- return true;
-}
-
-/**
- * Delete all page break deleted.
- *
- * @param int $sid question survey id.
- */
-function questionnaire_delete_pagebreaks($sid) {
- global $DB;
- $DB->delete_records_select(
- 'questionnaire_question',
- 'surveyid = :sid AND deleted IS NOT NULL AND type_id = :type_id',
- [
- 'sid' => $sid,
- 'type_id' => QUESPAGEBREAK,
- ]
- );
-}
-
-/**
- * Get a survey selection records.
- * @param int $courseid
- * @param string $type
- * @return array|false
- */
-function questionnaire_get_survey_list($courseid = 0, $type = '') {
- global $DB;
-
- if ($courseid == 0) {
- if (isadmin()) {
- $sql = "SELECT id,name,courseid,realm,status " .
- "{questionnaire_survey} " .
- "ORDER BY realm,name ";
- $params = null;
- } else {
- return false;
- }
- } else {
- if ($type == 'public') {
- $sql = "SELECT s.id,s.name,s.courseid,s.realm,s.status,s.title,q.id as qid,q.name as qname " .
- "FROM {questionnaire} q " .
- "INNER JOIN {questionnaire_survey} s ON s.id = q.sid AND s.courseid = q.course " .
- "WHERE realm = ? " .
- "ORDER BY realm,name ";
- $params = [$type];
- } else if ($type == 'template') {
- $sql = "SELECT s.id,s.name,s.courseid,s.realm,s.status,s.title,q.id as qid,q.name as qname " .
- "FROM {questionnaire} q " .
- "INNER JOIN {questionnaire_survey} s ON s.id = q.sid AND s.courseid = q.course " .
- "WHERE (realm = ?) " .
- "ORDER BY realm,name ";
- $params = [$type];
- } else if ($type == 'private') {
- $sql = "SELECT s.id,s.name,s.courseid,s.realm,s.status,q.id as qid,q.name as qname " .
- "FROM {questionnaire} q " .
- "INNER JOIN {questionnaire_survey} s ON s.id = q.sid " .
- "WHERE s.courseid = ? and realm = ? " .
- "ORDER BY realm,name ";
- $params = [$courseid, $type];
- } else {
- // Current get_survey_list is called from function questionnaire_reset_userdata so we need to get a
- // complete list of all questionnaires in current course to reset them.
- $sql = "SELECT s.id,s.name,s.courseid,s.realm,s.status,q.id as qid,q.name as qname " .
- "FROM {questionnaire} q " .
- "INNER JOIN {questionnaire_survey} s ON s.id = q.sid AND s.courseid = q.course " .
- "WHERE s.courseid = ? " .
- "ORDER BY realm,name ";
- $params = [$courseid];
- }
- }
- return $DB->get_records_sql($sql, $params) ?? [];
-}
-
-/**
- * Get survey selection list.
- * @param int $courseid
- * @param string $type
- * @return array
- */
-function questionnaire_get_survey_select($courseid = 0, $type = '') {
- global $OUTPUT, $DB;
-
- $surveylist = [];
-
- if ($surveys = questionnaire_get_survey_list($courseid, $type)) {
- $strpreview = get_string('preview_questionnaire', 'questionnaire');
- foreach ($surveys as $survey) {
- $originalcourse = $DB->get_record('course', ['id' => $survey->courseid]);
- if (!$originalcourse) {
- // This should not happen, but we found a case where a public survey
- // still existed in a course that had been deleted, and so this
- // code lead to a notice, and a broken link. Since that is useless
- // we just skip surveys like this.
- continue;
- }
-
- // Prevent creating a copy of a public questionnaire IN THE SAME COURSE as the original.
- if (($type == 'public') && ($survey->courseid == $courseid)) {
- continue;
- } else {
- $args = "sid={$survey->id}&popup=1";
- if (!empty($survey->qid)) {
- $args .= "&qid={$survey->qid}";
- }
- $link = new moodle_url("/mod/questionnaire/preview.php?{$args}");
- $action = new popup_action('click', $link);
- $label = $OUTPUT->action_link(
- $link,
- $survey->qname . ' [' . $originalcourse->fullname . ']',
- $action,
- ['title' => $strpreview]
- );
- $surveylist[$type . '-' . $survey->id] = $label;
- }
- }
- }
- return $surveylist;
-}
-
-/**
- * Return the language string for the specified question type.
- * @param int $id
- * @return lang_string|mixed|string
- * @throws coding_exception
- */
-function questionnaire_get_type($id) {
- switch ($id) {
- case 1:
- return get_string('yesno', 'questionnaire');
- case 2:
- return get_string('textbox', 'questionnaire');
- case 3:
- return get_string('essaybox', 'questionnaire');
- case 4:
- return get_string('radiobuttons', 'questionnaire');
- case 5:
- return get_string('checkboxes', 'questionnaire');
- case 6:
- return get_string('dropdown', 'questionnaire');
- case 8:
- return get_string('ratescale', 'questionnaire');
- case 9:
- return get_string('date', 'questionnaire');
- case 10:
- return get_string('numeric', 'questionnaire');
- case 11:
- return get_string('slider', 'questionnaire');
- case 12:
- return get_string('file', 'questionnaire');
- case 100:
- return get_string('sectiontext', 'questionnaire');
- case 99:
- return get_string('sectionbreak', 'questionnaire');
- default:
- return $id;
- }
-}
-
-/**
- * This creates new events given as opendate and closedate by $questionnaire.
- * @param object $questionnaire
- * @return void
- */
-function questionnaire_set_events($questionnaire) {
- // Adding the questionnaire to the eventtable.
- global $DB;
- 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 <= QUESTIONNAIRE_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);
- }
- }
-}
-
-/**
- * Get users who have not completed the questionnaire
- *
- * @param object $cm
- * @param int $sid
- * @param bool $group single groupid
- * @param string $sort
- * @param bool $startpage
- * @param bool $pagecount
- * @return object the userrecords
- * @throws coding_exception
- * @throws dml_exception
- */
-function questionnaire_get_incomplete_users(
- $cm,
- $sid,
- $group = false,
- $sort = '',
- $startpage = false,
- $pagecount = false
-) {
-
- global $DB;
-
- $context = context_module::instance($cm->id);
-
- // First get all users who can complete this questionnaire.
- $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);
-
- // Nnow get all completed questionnaires.
- $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);
- // Now strike all completedusers from allusers.
- $allusers = array_diff($allusers, $completedusers);
- // For paging I use array_slice().
- if (($startpage !== false) && ($pagecount !== false)) {
- $allusers = array_slice($allusers, $startpage, $pagecount);
- }
- return $allusers;
-}
-
-/**
- * Called by HTML editor in showrespondents and Essay question. Based on question/essay/renderer.
- * Pending general solution to using the HTML editor outside of moodleforms in Moodle pages.
- * @param int $context
- * @return array
- */
-function questionnaire_get_editor_options($context) {
- return [
- 'subdirs' => 0,
- 'maxbytes' => 0,
- 'maxfiles' => -1,
- 'context' => $context,
- 'noclean' => 0,
- 'trusttext' => 0,
- ];
-}
-
-/**
- * Get the parent of a child question.
- * @param stdClass $question
- * @return array
- */
-function questionnaire_get_parent($question) {
- global $DB;
- $qid = $question->id;
- $parent = [];
- $dependquestion = $DB->get_record(
- 'questionnaire_question',
- ['id' => $question->dependquestionid],
- 'id, position, name, type_id'
- );
- if (is_object($dependquestion)) {
- $qdependchoice = '';
- switch ($dependquestion->type_id) {
- case QUESRADIO:
- case QUESDROP:
- case QUESCHECK:
- $dependchoice = $DB->get_record('questionnaire_quest_choice', ['id' => $question->dependchoiceid], 'id,content');
- $qdependchoice = $dependchoice->id;
- $dependchoice = $dependchoice->content;
-
- $contents = questionnaire_choice_values($dependchoice);
- if ($contents->modname) {
- $dependchoice = $contents->modname;
- }
- break;
- case QUESYESNO:
- switch ($question->dependchoiceid) {
- case 0:
- $dependchoice = get_string('yes');
- $qdependchoice = 'y';
- break;
- case 1:
- $dependchoice = get_string('no');
- $qdependchoice = 'n';
- break;
- }
- break;
- }
- // Qdependquestion, parenttype and qdependchoice fields to be used in preview mode.
- $parent[$qid]['qdependquestion'] = 'q' . $dependquestion->id;
- $parent[$qid]['qdependchoice'] = $qdependchoice;
- $parent[$qid]['parenttype'] = $dependquestion->type_id;
- // Other fields to be used in Questions edit mode.
- $parent[$qid]['position'] = $question->position;
- $parent[$qid]['name'] = $question->name;
- $parent[$qid]['content'] = $question->content;
- $parent[$qid]['parentposition'] = $dependquestion->position;
- $parent[$qid]['parent'] = format_string($dependquestion->name) . '->' . format_string($dependchoice);
- }
- return $parent;
-}
-
-/**
- * Get parent position of all child questions in current questionnaire.
- * Use the parent with the largest position value.
- *
- * @param array $questions
- * @return array An array with Child-ID->Parentposition.
- */
-function questionnaire_get_parent_positions($questions) {
- $parentpositions = [];
- foreach ($questions as $question) {
- foreach ($question->dependencies as $dependency) {
- $dependquestion = $dependency->dependquestionid;
- if (isset($dependquestion) && $dependquestion != 0) {
- $childid = $question->id;
- $parentpos = $questions[$dependquestion]->position;
-
- if (!isset($parentpositions[$childid])) {
- $parentpositions[$childid] = $parentpos;
- }
- if (isset($parentpositions[$childid]) && $parentpos > $parentpositions[$childid]) {
- $parentpositions[$childid] = $parentpos;
- }
- }
- }
- }
- return $parentpositions;
-}
-
-/**
- * Get child position of all parent questions in current questionnaire.
- * Use the child with the smallest position value.
- *
- * @param array $questions
- * @return array An array with Parent-ID->Childposition.
- */
-function questionnaire_get_child_positions($questions) {
- $childpositions = [];
- foreach ($questions as $question) {
- foreach ($question->dependencies as $dependency) {
- $dependquestion = $dependency->dependquestionid;
- if (isset($dependquestion) && $dependquestion != 0) {
- $parentid = $questions[$dependquestion]->id; // Equals $dependquestion?.
- $childpos = $question->position;
-
- if (!isset($childpositions[$parentid])) {
- $childpositions[$parentid] = $childpos;
- }
-
- if (isset($childpositions[$parentid]) && $childpos < $childpositions[$parentid]) {
- $childpositions[$parentid] = $childpos;
- }
- }
- }
- }
- return $childpositions;
-}
-
-/**
- * Check that the needed page breaks are present to separate child questions.
- * @param stdClass $questionnaire
- * @return false|lang_string|string
- */
-function questionnaire_check_page_breaks($questionnaire) {
- global $DB;
- $msg = '';
- // Store the new page breaks ids.
- $newpbids = [];
- $delpb = 0;
- $sid = $questionnaire->survey->id;
- $positions = [];
- if (
- $questions = $DB->get_records_select(
- 'questionnaire_question',
- 'surveyid = :sid AND deleted IS NULL',
- ['sid' => $sid],
- 'position'
- )
- ) {
- foreach ($questions as $key => $qu) {
- $newqu = new stdClass();
- $newqu->question_id = $key;
- $newqu->type_id = $qu->type_id;
- $newqu->qname = $qu->name;
- $newqu->qpos = $qu->position;
-
- $dependencies = $DB->get_records(
- 'questionnaire_dependency',
- ['questionid' => $key,
- 'surveyid' => $sid],
- 'id ASC',
- 'id, dependquestionid, dependchoiceid, dependlogic'
- );
- $newqu->dependencies = $dependencies ?? [];
- $positions[] = (array)$newqu;
- }
- }
- $count = count($positions);
-
- for ($i = $count - 1; $i >= 0; $i--) {
- $qu = $positions[$i];
- $questionnb = $i;
- $prevqu = null;
- $prevtypeid = null;
- if ($i > 0) {
- $prevqu = $positions[$i - 1];
- $prevtypeid = $prevqu['type_id'];
- }
- if ($qu['type_id'] == QUESPAGEBREAK) {
- $questionnb--;
- // If more than one consecutive page breaks, remove extra one(s).
- // Remove that extra page break in 1st position.
- if ($prevtypeid == QUESPAGEBREAK || $i == $count - 1 || $qu['qpos'] == 1) {
- $qid = $qu['question_id'];
- $delpb++;
- $msg .= get_string("checkbreaksremoved", "questionnaire", $delpb) . ' ';
- // Need to reload questions.
- if (
- $questions = $DB->get_records_select(
- 'questionnaire_question',
- 'surveyid = :sid AND deleted IS NULL',
- ['sid' => $sid],
- 'id'
- )
- ) {
- $DB->set_field('questionnaire_question', 'deleted', time(), ['id' => $qid, 'surveyid' => $sid]);
- $select = 'surveyid = :sid AND deleted IS NULL AND position > :pos';
- $records = $DB->get_records_select(
- 'questionnaire_question',
- $select,
- ['sid' => $sid, 'pos' => $questions[$qid]->position],
- 'position ASC'
- );
- if ($records) {
- foreach ($records as $record) {
- $DB->set_field('questionnaire_question', 'position', $record->position - 1, ['id' => $record->id]);
- }
- }
- }
- }
- }
- // Add pagebreak between question child and not dependent question that follows.
- if ($qu['type_id'] != QUESPAGEBREAK) {
- if ($prevqu) {
- $prevdependencies = $prevqu['dependencies'];
- $outerdependencies = count($qu['dependencies']) >= count($prevdependencies) ?
- $qu['dependencies'] : $prevdependencies;
- $innerdependencies = count($qu['dependencies']) < count($prevdependencies) ?
- $qu['dependencies'] : $prevdependencies;
-
- $okeys = [];
- $ikeys = [];
- foreach ($outerdependencies as $okey => $outerdependency) {
- foreach ($innerdependencies as $ikey => $innerdependency) {
- if (
- $outerdependency->dependquestionid === $innerdependency->dependquestionid &&
- $outerdependency->dependchoiceid === $innerdependency->dependchoiceid &&
- $outerdependency->dependlogic === $innerdependency->dependlogic
- ) {
- $okeys[] = $okey;
- $ikeys[] = $ikey;
- }
- }
- }
-
- foreach ($okeys as $key) {
- if (key_exists($key, $outerdependencies)) {
- unset($outerdependencies[$key]);
- }
- }
- foreach ($ikeys as $key) {
- if (key_exists($key, $innerdependencies)) {
- unset($innerdependencies[$key]);
- }
- }
-
- $diffdependencies = count($outerdependencies) + count($innerdependencies);
-
- if (
- ($prevtypeid != QUESPAGEBREAK && $diffdependencies != 0) ||
- (!isset($qu['dependencies']) && isset($prevdependencies))
- ) {
- $sql = "SELECT MAX(position) as maxpos
- FROM {questionnaire_question}
- WHERE surveyid = :sid
- AND deleted IS NULL";
- if ($record = $DB->get_record_sql($sql, ['sid' => $questionnaire->survey->id])) {
- $pos = $record->maxpos + 1;
- } else {
- $pos = 1;
- }
- $question = new stdClass();
- $question->surveyid = $questionnaire->survey->id;
- $question->type_id = QUESPAGEBREAK;
- $question->position = $pos;
- $question->content = 'break';
-
- if (!($newqid = $DB->insert_record('questionnaire_question', $question))) {
- return (false);
- }
- $newpbids[] = $newqid;
- $questionnaire = new questionnaire($course, $cm, $questionnaire->id, null);
- $questionnaire->move_question($newqid, $qu['qpos']);
- }
- }
- }
- }
- if (empty($newpbids) && !$msg) {
- $msg = get_string('checkbreaksok', 'questionnaire');
- } else if ($newpbids) {
- $msg .= get_string('checkbreaksadded', 'questionnaire') . ' ';
- $newpbids = array_reverse($newpbids);
- $questionnaire = new questionnaire($course, $cm, $questionnaire->id, null);
- foreach ($newpbids as $newpbid) {
- $msg .= $questionnaire->questions[$newpbid]->position . ' ';
- }
- }
- return($msg);
-}
-
-/**
- * Code snippet used to set up the questionform.
- * @param stdClass $questionnaire
- * @param int $qid
- * @param int $qtype
- * @return mixed|\mod_questionnaire\question\question
- */
-function questionnaire_prep_for_questionform($questionnaire, $qid, $qtype) {
- $context = context_module::instance($questionnaire->cm->id);
- if ($qid != 0) {
- $question = clone($questionnaire->questions[$qid]);
- $question->qid = $question->id;
- $question->sid = $questionnaire->survey->id;
- $question->id = $questionnaire->cm->id;
- $draftideditor = file_get_submitted_draft_itemid('question');
- $content = file_prepare_draft_area(
- $draftideditor,
- $context->id,
- 'mod_questionnaire',
- 'question',
- $qid,
- ['subdirs' => true],
- $question->content
- );
- $question->content = ['text' => $content, 'format' => FORMAT_HTML, 'itemid' => $draftideditor];
-
- if (isset($question->dependencies)) {
- foreach ($question->dependencies as $dependencies) {
- if ($dependencies->dependandor === "and") {
- $question->dependquestions_and[] = $dependencies->dependquestionid . ',' . $dependencies->dependchoiceid;
- $question->dependlogic_and[] = $dependencies->dependlogic;
- } else if ($dependencies->dependandor === "or") {
- $question->dependquestions_or[] = $dependencies->dependquestionid . ',' . $dependencies->dependchoiceid;
- $question->dependlogic_or[] = $dependencies->dependlogic;
- }
- }
- }
- } else {
- $question = \mod_questionnaire\question\question::question_builder($qtype);
- $question->sid = $questionnaire->survey->id;
- $question->id = $questionnaire->cm->id;
- $question->type_id = $qtype;
- $question->type = '';
- $draftideditor = file_get_submitted_draft_itemid('question');
- $content = file_prepare_draft_area(
- $draftideditor,
- $context->id,
- 'mod_questionnaire',
- 'question',
- 0,
- ['subdirs' => true],
- ''
- );
- $question->content = ['text' => $content, 'format' => FORMAT_HTML, 'itemid' => $draftideditor];
- }
- return $question;
-}
-
-/**
- * Get the standard page contructs and check for validity.
- * @param int $id The coursemodule id.
- * @param int $a The module instance id.
- * @return array An array with the $cm, $course, and $questionnaire records in that order.
- */
-function questionnaire_get_standard_page_items($id = null, $a = null) {
- global $DB;
-
- if ($id) {
- if (! $cm = get_coursemodule_from_id('questionnaire', $id)) {
- throw new \moodle_exception('invalidcoursemodule', 'mod_questionnaire');
- }
-
- if (! $course = $DB->get_record("course", ["id" => $cm->course])) {
- throw new \moodle_exception('coursemisconf', 'mod_questionnaire');
- }
-
- if (! $questionnaire = $DB->get_record("questionnaire", ["id" => $cm->instance])) {
- throw new \moodle_exception('invalidcoursemodule', 'mod_questionnaire');
- }
- } else {
- if (! $questionnaire = $DB->get_record("questionnaire", ["id" => $a])) {
- throw new \moodle_exception('invalidcoursemodule', 'mod_questionnaire');
- }
- if (! $course = $DB->get_record("course", ["id" => $questionnaire->course])) {
- throw new \moodle_exception('coursemisconf', 'mod_questionnaire');
- }
- if (! $cm = get_coursemodule_from_instance("questionnaire", $questionnaire->id, $course->id)) {
- throw new \moodle_exception('invalidcoursemodule', 'mod_questionnaire');
- }
- }
-
- return ([$cm, $course, $questionnaire]);
-}
-
-
-/**
- * Count responses already saved for that question.
- *
- * @param int $qid question id.
- * @param int $qtype question type.
- * @return int number or 0 if responses were not found.
- */
-function count_reponses_question(int $qid, int $qtype): int {
- global $DB;
-
- $countresps = 0;
- if ($qtype != QUESSECTIONTEXT) {
- $responsetable = $DB->get_field('questionnaire_question_type', 'response_table', ['typeid' => $qtype]);
- if (!empty($responsetable)) {
- $countresps = $DB->count_records('questionnaire_' . $responsetable, ['question_id' => $qid]);
- }
- }
-
- return $countresps;
-}
-
-
-/**
- * Create options for remove old responses in the questionare.
- *
- * @return array
- */
-function questionnaire_create_remove_options() {
- $options = [];
- $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;
-}
-
-/**
- * Delete all the old responses when we have setting the questionnaire.
- *
- * @throws coding_exception
- * @throws dml_exception
- */
-function questionnaire_delete_old_responses() {
- 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)";
-
- // Get all old response IDs from questionnaires.
- $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");
- }
- // Tables to delete responses from.
- $responsetables = [
- 'questionnaire_response_bool', 'questionnaire_response_date', 'questionnaire_resp_multiple',
- 'questionnaire_response_other', 'questionnaire_response_rank', 'questionnaire_resp_single',
- 'questionnaire_response_text'];
-
- // Delete related response data.
- foreach ($responsetables as $tablename) {
- $DB->delete_records_list($tablename, 'response_id', $oldresponsesid);
- }
-
- // Delete from the main response table.
- $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);
- }
- }
-}
diff --git a/mod_form.php b/mod_form.php
index 9b6439bbc..b405448d8 100644
--- a/mod_form.php
+++ b/mod_form.php
@@ -17,8 +17,6 @@
defined('MOODLE_INTERNAL') || die();
require_once($CFG->dirroot . '/course/moodleform_mod.php');
-require_once($CFG->dirroot . '/mod/questionnaire/questionnaire.class.php');
-require_once($CFG->dirroot . '/mod/questionnaire/locallib.php');
/**
* print the form to add or edit a questionnaire-instance
@@ -34,9 +32,10 @@ class mod_questionnaire_mod_form extends moodleform_mod {
*/
protected function definition() {
global $COURSE, $CFG;
- global $questionnairetypes, $questionnairerespondents, $questionnaireresponseviewers, $autonumbering;
- $questionnaire = new questionnaire($COURSE, $this->_cm, $this->_instance, null);
+ $surveyid = !empty($this->_instance)
+ ? \mod_questionnaire\questionnaire::from_instanceid($this->_instance)->surveyid()
+ : null;
$mform =& $this->_form;
@@ -54,17 +53,32 @@ protected function definition() {
$mform->addElement('header', 'questionnairehdr', get_string('responseoptions', 'questionnaire'));
- $mform->addElement('select', 'qtype', get_string('qtype', 'questionnaire'), $questionnairetypes);
+ $mform->addElement(
+ 'select',
+ 'qtype',
+ get_string('qtype', 'questionnaire'),
+ \mod_questionnaire\form_options::response_frequency()
+ );
$mform->addHelpButton('qtype', 'qtype', 'questionnaire');
$mform->addElement('hidden', 'cannotchangerespondenttype');
$mform->setType('cannotchangerespondenttype', PARAM_INT);
- $mform->addElement('select', 'respondenttype', get_string('respondenttype', 'questionnaire'), $questionnairerespondents);
+ $mform->addElement(
+ 'select',
+ 'respondenttype',
+ get_string('respondenttype', 'questionnaire'),
+ \mod_questionnaire\form_options::respondent_type()
+ );
$mform->addHelpButton('respondenttype', 'respondenttype', 'questionnaire');
$mform->disabledIf('respondenttype', 'cannotchangerespondenttype', 'eq', 1);
- $mform->addElement('select', 'resp_view', get_string('responseview', 'questionnaire'), $questionnaireresponseviewers);
- $mform->addHelpButton('resp_view', 'responseview', 'questionnaire');
+ $mform->addElement(
+ 'select',
+ 'respview',
+ get_string('responseview', 'questionnaire'),
+ \mod_questionnaire\form_options::response_viewer()
+ );
+ $mform->addHelpButton('respview', 'responseview', 'questionnaire');
$notificationoptions = [
0 => get_string('no'),
@@ -82,7 +96,12 @@ protected function definition() {
$mform->addElement('select', 'navigate', get_string('navigate', 'questionnaire'), $options);
$mform->addHelpButton('navigate', 'navigate', 'questionnaire');
- $mform->addElement('select', 'autonum', get_string('autonumbering', 'questionnaire'), $autonumbering);
+ $mform->addElement(
+ 'select',
+ 'autonum',
+ get_string('autonumbering', 'questionnaire'),
+ \mod_questionnaire\form_options::auto_numbering()
+ );
$mform->addHelpButton('autonum', 'autonumbering', 'questionnaire');
// Default = autonumber both questions and pages.
$mform->setDefault('autonum', 3);
@@ -96,18 +115,14 @@ protected function definition() {
}
$mform->addElement('select', 'grade', get_string('grade', 'questionnaire'), $grades);
- if (empty($questionnaire->sid)) {
- if (!isset($questionnaire->id)) {
- $questionnaire->id = 0;
- }
-
+ if (empty($surveyid)) {
$mform->addElement('header', 'contenthdr', get_string('contentoptions', 'questionnaire'));
$mform->addHelpButton('contenthdr', 'createcontent', 'questionnaire');
$mform->addElement('radio', 'create', get_string('createnew', 'questionnaire'), '', 'new-0');
// Retrieve existing private questionnaires from current course.
- $surveys = questionnaire_get_survey_select($COURSE->id, 'private');
+ $surveys = \mod_questionnaire\local\survey\survey::get_private_questionnaires($COURSE->id);
if (!empty($surveys)) {
$prelabel = get_string('useprivate', 'questionnaire');
foreach ($surveys as $value => $label) {
@@ -116,7 +131,7 @@ protected function definition() {
}
}
// Retrieve existing template questionnaires from this site.
- $surveys = questionnaire_get_survey_select($COURSE->id, 'template');
+ $surveys = \mod_questionnaire\local\survey\survey::get_template_questionnaires($COURSE->id);
if (!empty($surveys)) {
$prelabel = get_string('usetemplate', 'questionnaire');
foreach ($surveys as $value => $label) {
@@ -133,7 +148,7 @@ protected function definition() {
}
// Retrieve existing public questionnaires from this site.
- $surveys = questionnaire_get_survey_select($COURSE->id, 'public');
+ $surveys = \mod_questionnaire\local\survey\survey::get_public_questionnaires($COURSE->id);
if (!empty($surveys)) {
$prelabel = get_string('usepublic', 'questionnaire');
foreach ($surveys as $value => $label) {
@@ -165,7 +180,7 @@ protected function definition() {
);
$mform->addHelpButton('removeafter', 'removeoldresponses', 'questionnaire');
// Just set default value when creating a new questionare.
- if (empty($questionnaire->sid)) {
+ if (empty($surveyid)) {
$defaultconfig = get_config('questionnaire', 'removeoldresponses');
$mform->setDefault('removeafter', $defaultconfig);
}
diff --git a/module.js b/module.js
deleted file mode 100644
index 93cdd157d..000000000
--- a/module.js
+++ /dev/null
@@ -1,399 +0,0 @@
-// This file is part of Moodle - http://moodle.org/
-//
-// Moodle is free software: you can redistribute it and/or modify
-// it under the terms of the GNU General Public License as published by
-// the Free Software Foundation, either version 3 of the License, or
-// (at your option) any later version.
-//
-// Moodle is distributed in the hope that it will be useful,
-// but WITHOUT ANY WARRANTY; without even the implied warranty of
-// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-// GNU General Public License for more details.
-//
-// You should have received a copy of the GNU General Public License
-// along with Moodle. If not, see .
-
-/**
- * JavaScript library for the quiz module.
- *
- * @package mod
- * @subpackage questionnaire
- * @copyright
- * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
- */
-
-/*
- * A workaround for MSIE versions < 10 which do not recognize classList. Answer by Paulpro at:
- * http://stackoverflow.com/questions/6787383/what-is-the-solution-to-remove-add-a-class-in-pure-javascript.
- * */
-
-/**
- * @param {HTMLElement} el
- * @param {string} aclass
- */
-function addClass(el, aclass) {
- el.className += ' ' + aclass;
-}
-
-/**
- * @param {HTMLElement} el
- * @param {string} aclass
- */
-function removeClass(el, aclass) {
- var elClass = ' ' + el.className + ' ';
- while (elClass.indexOf(' ' + aclass + ' ') != -1) {
- elClass = elClass.replace(' ' + aclass + ' ', '');
- }
- el.className = elClass;
-}
-// End classList workaround.
-
-/**
- * Javascript for hiding/displaying children questions on preview page of
- * questionnaire with conditional branching.
- */
-function depend(children, choices) {
- children = children.split(',');
- choices = choices.split(',');
- var childrenlength = children.length;
- var choiceslength = choices.length;
- var child = null;
- var choice = null;
- for (var i = 0; i < childrenlength; i++) {
- child = children[i];
- var q = document.getElementById(child);
- if (q) {
- var radios = q.getElementsByTagName('input');
- var radiolength = radios.length;
- var droplists = q.getElementsByTagName('select');
- var droplistlength = droplists.length;
- var textareas = q.getElementsByTagName('textarea');
- var textarealength = textareas.length;
- for (var k = 0; k < choiceslength; k++) {
- var j, m, n;
- choice = choices[k];
- if (child == choice) {
- // If this browser version accepts classList.
- if (typeof document !== "undefined" && ("classList" in document.createElement("a"))) {
- q.classList.add('qn-container');
- // If this browser version DOES NOT accept classList (e.g. MSIE < 10)
- } else {
- addClass(q, 'qn-container');
- }
- for (j = 0; j < radiolength; j++) {
- var radio = radios[j];
- radio.disabled = false;
- }
- for (m = 0; m < droplistlength; m++) {
- var droplist = droplists[m];
- droplist.disabled = false;
- }
- delete children[i];
- } else if (children[i]) {
- if (typeof document !== "undefined" && ("classList" in document.createElement("a"))) {
- q.classList.remove('qn-container');
- q.classList.add('hidedependquestion');
- } else {
- removeClass(q, 'qn-container');
- }
- addClass(q, 'hidedependquestion');
- for (j = 0; j < radiolength; j++) {
- var radio = radios[j];
- radio.disabled = true;
- radio.checked = false;
- radio.value = '';
- }
- for (m = 0; m < droplistlength; m++) {
- var droplist = droplists[m];
- droplist.selectedIndex = 0;
- droplist.disabled = true;
- droplist.checked = false;
- }
- for (n = 0; n < textarealength; n++) {
- var textarea = textareas[n];
- textarea.value = '';
- }
- }
- }
- }
- }
-}
-
-/* exported dependdrop */
-
-/**
- * @param {string} qId
- * @param {*} children
- */
-function dependdrop(qId, children) {
- var e = document.getElementById(qId);
- var choice = e.options[e.selectedIndex].value;
- depend(children, choice);
-}
-// End conditional branching functions.
-
-// When respondent enters text in !other field, corresponding
-// radio button OR check box is automatically checked.
-/* exported other_check */
-/**
- * @param {string | void} name
- */
-function other_check(name) {
- var other = name.split("_");
- var other = name.slice(name.indexOf("o") + 1);
- if (other.indexOf("]") != -1) {
- other = other.slice(0, other.indexOf("]"));
- }
- var f = document.getElementById("phpesp_response");
- for (var i = 0; i <= f.elements.length; i++) {
- if (f.elements[i].value == other) {
- f.elements[i].checked = true;
- break;
- }
- }
-}
-
-// Automatically empty an !other text input field if another Radio button is clicked.
-/* exported other_check_empty */
-/**
- * @param {string} name
- */
-function other_check_empty(name, value) {
- var f = document.getElementById("phpesp_response");
- var i;
- for (i = 0; i < f.elements.length; i++) {
- if ((f.elements[i].name == name) && f.elements[i].value.substr(0, 6) == "other_") {
- f.elements[i].checked = true;
- var otherid = f.elements[i].name + "_" + f.elements[i].value.substring(6);
- var other = document.getElementsByName(otherid);
- if (value.substr(0, 6) != "other_") {
- other[0].value = "";
- } else {
- other[0].focus();
- }
- var actualbuttons = document.getElementsByName(name);
- for (i = 0; i <= actualbuttons.length; i++) {
- if (actualbuttons[i].value == value) {
- actualbuttons[i].checked = true;
- break;
- }
- }
- break;
- }
- }
-}
-
-// In a Rate question type of sub-type Order : automatically uncheck a Radio button
-// when another radio button in the same column is clicked.
-/* exported other_rate_uncheck */
-/**
- * @param {string} name
- * @param {string} value
- */
-function other_rate_uncheck(name, value) {
- var col_name = name.substr(0, name.indexOf("_"));
- var inputbuttons = document.getElementsByTagName("input");
- for (var i = 0; i <= inputbuttons.length - 1; i++) {
- var button = inputbuttons[i];
- if (button.type == "radio" && button.name != name && button.value == value
- && button.name.substr(0, name.indexOf("_")) == col_name) {
- button.checked = false;
- }
- }
-}
-
-// Empty an !other text input when corresponding Check Box is clicked (supposedly to empty it).
-/* exported checkbox_empty */
-/**
- * @param {string} name
- */
-function checkbox_empty(name) {
- var actualbuttons = document.getElementsByName(name);
- for (var i = 0; i <= actualbuttons.length; i++) {
- if (actualbuttons[i].value.substr(0, 6) == "other_") {
- name = name.substring(0, name.length - 2) + actualbuttons[i].value.substring(5);
- var othertext = document.getElementsByName(name);
- if (othertext[0].value == "" && actualbuttons[i].checked == true) {
- othertext[0].focus();
- } else {
- othertext[0].value = "";
- }
- break;
- }
- }
-}
-
-
-M.mod_questionnaire = M.mod_questionnaire || {};
-
-/* exported Y */
-/* exported e */
-M.mod_questionnaire.init_attempt_form = function() {
- require(['core_form/changechecker'], function(FormChangeChecker) {
- FormChangeChecker.watchFormById('phpesp_response');
- });
-};
-
-M.mod_questionnaire.init_sendmessage = function(Y) {
- Y.on('click', function() {
- Y.all('input.usercheckbox').each(function() {
- this.set('checked', 'checked');
- });
- }, '#checkall');
-
- Y.on('click', function() {
- Y.all('input.usercheckbox').each(function() {
- this.set('checked', '');
- });
- }, '#checknone');
-
- Y.on('click', function() {
- Y.all('input.usercheckbox').each(function() {
- if (this.get('alt') == 0) {
- this.set('checked', 'checked');
- } else {
- this.set('checked', '');
- }
- });
- }, '#checknotstarted');
-
- Y.on('click', function() {
- Y.all('input.usercheckbox').each(function() {
- if (this.get('alt') == 1) {
- this.set('checked', 'checked');
- } else {
- this.set('checked', '');
- }
- });
- }, '#checkstarted');
-
-};
-
-M.mod_questionnaire.init_printing = function(Y) {
- var printbutton = Y.one('#print-button');
- if (printbutton) {
- printbutton.on('click', function(e) {
- e.preventDefault();
- window.print();
- });
- }
-};
-M.mod_questionnaire.init_slider = function() {
- const allRanges = document.querySelectorAll(".question-slider");
- allRanges.forEach(wrap => {
- const range = wrap.querySelector("input.questionnaire-slider");
- const bubble = wrap.querySelector(".bubble");
- const labels = {
- leftlabel: wrap.querySelector(".left-side-label"),
- middlelabel: wrap.querySelector(".middle-side-label"),
- rightlabel: wrap.querySelector(".right-side-label")
- };
-
- range.addEventListener("input", () => {
- setBubble(range, bubble);
- createAccessibilityHeading(range, bubble, labels);
- });
- setBubble(range, bubble);
- createAccessibilityHeading(range, bubble, labels);
- });
-
- function setBubble(range, bubble) {
- const val = range.value;
- const min = range.min ? range.min : 0;
- const max = range.max ? range.max : 100;
- var newVal = Number(((val - min) * 100) / (max - min));
- var positiveVal = '';
- if (range.min && range.min < 0) {
- if (range.max && range.max > 0) {
- if (val > 0) {
- positiveVal = '+';
- }
- }
- }
- bubble.innerHTML = positiveVal + val;
-
- // Sorta magic numbers based on size of the native UI thumb
- bubble.style.left = `calc(${newVal}% + (${8 - newVal * 0.15}px))`;
- }
-
- /**
- * Adds accessibility support by generating an h2 element with relevant text.
- * @param {HTMLElement} range - The range input element.
- * @param {HTMLElement} bubble - The bubble element.
- * @param {Object} labels - The object containing label elements.
- */
- function createAccessibilityHeading(range, bubble, labels) {
- const min = range.min ? range.min : 0;
- const max = range.max ? range.max : 100;
- const step = range.step ? range.step : 1;
- const centerValues = calculateCenterValues(range);
-
- const accesshideElement = document.createElement('h2');
- accesshideElement.classList.add('accesshide');
-
- const a = {
- min,
- max,
- leftlabel: labels.leftlabel.innerHTML,
- rightlabel: labels.rightlabel.innerHTML,
- middlelabel: labels.middlelabel.innerHTML,
- centreval: centerValues[0],
- centreval1: centerValues[0],
- centreval2: centerValues[1],
- };
-
- const rangeNum = max - min;
- const numSteps = rangeNum / step;
-
- const middleLabel = (numSteps % 2 !== 0)
- ? (labels.middlelabel.innerHTML
- ? M.util.get_string('middlepartwithtwovalues', 'questionnaire', a)
- : M.util.get_string('middlepartwithtwovaluesdefault', 'questionnaire', a))
- : (labels.middlelabel.innerHTML
- ? M.util.get_string('middlepart', 'questionnaire', a)
- : M.util.get_string('middlepartdefault', 'questionnaire', a));
-
- const leftPart = labels.leftlabel.innerHTML ? M.util.get_string('leftpart', 'questionnaire', a)
- : M.util.get_string('leftpartdefault', 'questionnaire', a);
-
- const middlePart = middleLabel ? middleLabel : '';
-
- const rightPart = labels.rightlabel.innerHTML
- ? M.util.get_string('rightpart', 'questionnaire', a) : M.util.get_string('rightpartdefault', 'questionnaire', a);
-
- // Include a whitespace to accommodate a setting of the NVDA screen reader.
- const whitespace = '\xa0';
- accesshideElement.textContent = whitespace + M.util.get_string('where', 'questionnaire', a) + leftPart + middlePart + rightPart;
- bubble.appendChild(accesshideElement);
- }
-
- /**
- * Calculates the center value(s) based on the given range.
- * @param {Object} range - The range object containing properties: min, max, step.
- * @returns {Array} - An array containing the center value(s).
- */
- function calculateCenterValues(range) {
- const min = parseInt(range.min);
- const max = parseInt(range.max);
- const step = parseInt(range.step);
- const rangeNum = max - min;
-
- // Calculate the number of steps.
- const numSteps = rangeNum / step;
-
- // Calculate the center value(s).
- const centerValues = [];
- if (numSteps % 2 === 0) {
- // Even number of steps, return single center value.
- const center = (min + max) / 2;
- centerValues.push(center);
- } else {
- // Odd number of steps, calculate lower and upper center values.
- const lowerCenter = min + Math.floor(numSteps / 2) * step;
- const upperCenter = lowerCenter + step;
- centerValues.push(lowerCenter, upperCenter);
- }
-
- return centerValues;
- }
-};
diff --git a/myreport.php b/myreport.php
index faf958619..22d5896dd 100644
--- a/myreport.php
+++ b/myreport.php
@@ -24,7 +24,9 @@
*/
require_once("../../config.php");
-require_once($CFG->dirroot . '/mod/questionnaire/questionnaire.class.php');
+
+use mod_questionnaire\questionnaire;
+use mod_questionnaire\output\reportpage;
$instance = required_param('instance', PARAM_INT); // Questionnaire ID.
$userid = optional_param('user', $USER->id, PARAM_INT);
@@ -33,23 +35,17 @@
$action = optional_param('action', 'summary', PARAM_ALPHA);
$currentgroupid = optional_param('group', 0, PARAM_INT); // Groupid.
-if (! $questionnaire = $DB->get_record("questionnaire", ["id" => $instance])) {
- throw new \moodle_exception('incorrectquestionnaire', 'mod_questionnaire');
-}
-if (! $course = $DB->get_record("course", ["id" => $questionnaire->course])) {
- throw new \moodle_exception('coursemisconf', 'mod_questionnaire');
-}
-if (! $cm = get_coursemodule_from_instance("questionnaire", $questionnaire->id, $course->id)) {
- throw new \moodle_exception('invalidcoursemodule', 'mod_questionnaire');
-}
+$questionnaire = questionnaire::from_instanceid($instance);
+$course = $questionnaire->course();
+$cm = $questionnaire->coursemodule();
require_course_login($course, true, $cm);
-$context = context_module::instance($cm->id);
-$questionnaire->canviewallgroups = has_capability('moodle/site:accessallgroups', $context);
+
// Should never happen, unless called directly by a snoop...
-if (!has_capability('mod/questionnaire:readownresponses', $context) || $userid != $USER->id) {
+if (!$questionnaire->capabilities()->can_read_own_responses() || $userid != $USER->id) {
throw new \moodle_exception('nopermissions', 'mod_questionnaire');
}
+
$url = new moodle_url($CFG->wwwroot . '/mod/questionnaire/myreport.php', ['instance' => $instance]);
if (isset($userid)) {
$url->param('userid', $userid);
@@ -57,40 +53,29 @@
if (isset($byresponse)) {
$url->param('byresponse', $byresponse);
}
-
if (isset($currentgroupid)) {
$url->param('group', $currentgroupid);
}
-
if (isset($action)) {
$url->param('action', $action);
}
$PAGE->set_url($url);
-$PAGE->set_context($context);
+$PAGE->set_context($questionnaire->context());
$PAGE->set_title(get_string('questionnairereport', 'questionnaire'));
$PAGE->set_heading(format_string($course->fullname));
-$questionnaire = new questionnaire($course, $cm, 0, $questionnaire);
-// Add renderer and page objects to the questionnaire object for display use.
-$questionnaire->add_renderer($PAGE->get_renderer('mod_questionnaire'));
-$questionnaire->add_page(new \mod_questionnaire\output\reportpage());
-
-$sid = $questionnaire->survey->id;
-$courseid = $course->id;
+$renderer = $PAGE->get_renderer('mod_questionnaire');
+$page = new reportpage();
-// Tab setup.
-if (!isset($SESSION->questionnaire)) {
- $SESSION->questionnaire = new stdClass();
-}
-$SESSION->questionnaire->current_tab = 'myreport';
+$sid = $questionnaire->surveyid();
+$courseid = $questionnaire->courseid();
switch ($action) {
case 'summary':
- if (empty($questionnaire->survey)) {
+ if (!$questionnaire->survey()) {
throw new \moodle_exception('surveynotexists', 'mod_questionnaire');
}
- $SESSION->questionnaire->current_tab = 'mysummary';
$resps = $questionnaire->get_responses($userid);
$rids = array_keys($resps);
if (count($resps) > 1) {
@@ -100,71 +85,46 @@
}
// Print the page header.
- echo $questionnaire->renderer->header();
+ echo $renderer->header();
// Print the tabs.
- include('tabs.php');
+ (new \mod_questionnaire\output\tabs($questionnaire, 'mysummary', $currentgroupid))->render($page);
- $questionnaire->page->add_to_page('myheaders', $titletext);
- $questionnaire->survey_results($rids, $USER->id);
+ $page->add_to_page('myheaders', $titletext);
+ $questionnaire->reporter($renderer, $page)->survey_results($rids, $USER->id);
- echo $questionnaire->renderer->render($questionnaire->page);
+ echo $renderer->render($page);
// Finish the page.
- echo $questionnaire->renderer->footer($course);
+ echo $renderer->footer($course);
break;
case 'vall':
- if (empty($questionnaire->survey)) {
+ if (!$questionnaire->survey()) {
throw new \moodle_exception('surveynotexists', 'mod_questionnaire');
}
- $SESSION->questionnaire->current_tab = 'myvall';
- $questionnaire->add_user_responses($userid);
+ $questionnaire->reporter($renderer, $page)->add_user_responses($userid);
$titletext = get_string('myresponses', 'questionnaire');
// Print the page header.
- echo $questionnaire->renderer->header();
+ echo $renderer->header();
// Print the tabs.
- include('tabs.php');
+ (new \mod_questionnaire\output\tabs($questionnaire, 'myvall', $currentgroupid))->render($page);
- $questionnaire->page->add_to_page('myheaders', $titletext);
- $questionnaire->view_all_responses();
- echo $questionnaire->renderer->render($questionnaire->page);
+ $page->add_to_page('myheaders', $titletext);
+ $questionnaire->reporter($renderer, $page)->view_all_responses();
+ echo $renderer->render($page);
// Finish the page.
- echo $questionnaire->renderer->footer($course);
+ echo $renderer->footer($course);
break;
case 'vresp':
- if (empty($questionnaire->survey)) {
+ if (!$questionnaire->survey()) {
throw new \moodle_exception('surveynotexists', 'mod_questionnaire');
}
- $SESSION->questionnaire->current_tab = 'mybyresponse';
- $usergraph = get_config('questionnaire', 'usergraph');
- if ($usergraph) {
- $charttype = $questionnaire->survey->chart_type;
- if ($charttype) {
- $PAGE->requires->js('/mod/questionnaire/javascript/RGraph/RGraph.common.core.js');
-
- switch ($charttype) {
- case 'bipolar':
- $PAGE->requires->js('/mod/questionnaire/javascript/RGraph/RGraph.bipolar.js');
- break;
- case 'hbar':
- $PAGE->requires->js('/mod/questionnaire/javascript/RGraph/RGraph.hbar.js');
- break;
- case 'radar':
- $PAGE->requires->js('/mod/questionnaire/javascript/RGraph/RGraph.radar.js');
- break;
- case 'rose':
- $PAGE->requires->js('/mod/questionnaire/javascript/RGraph/RGraph.rose.js');
- break;
- case 'vprogress':
- $PAGE->requires->js('/mod/questionnaire/javascript/RGraph/RGraph.vprogress.js');
- break;
- }
- }
- }
+ // The chart_renderer queues RGraph scripts on $PAGE itself when build_scoreboard
+ // emits a chart, so no preload needed here.
$resps = $questionnaire->get_responses($userid);
// All participants.
@@ -172,8 +132,6 @@
$respsuser = $questionnaire->get_responses($userid);
- $SESSION->questionnaire->numrespsallparticipants = count($respsallparticipants);
- $SESSION->questionnaire->numselectedresps = $SESSION->questionnaire->numrespsallparticipants;
$iscurrentgroupmember = false;
// Available group modes (0 = no groups; 1 = separate groups; 2 = visible groups).
@@ -188,7 +146,7 @@
if ($groupmode == 1) {
$questionnairegroups = groups_get_all_groups($course->id, $userid);
}
- if ($groupmode == 2 || $questionnaire->canviewallgroups) {
+ if ($groupmode == 2 || $questionnaire->capabilities()->can_view_all_groups()) {
$questionnairegroups = groups_get_all_groups($course->id);
}
@@ -201,7 +159,7 @@
if ($groupscount === 0 && $groupmode == 1) {
$currentgroupid = 0;
}
- if ($groupmode == 1 && !$questionnaire->canviewallgroups && $currentgroupid == 0) {
+ if ($groupmode == 1 && !$questionnaire->capabilities()->can_view_all_groups() && $currentgroupid == 0) {
$currentgroupid = $firstgroupid;
}
// If currentgroup is All Participants, current user is of course member of that "group"!
@@ -213,7 +171,7 @@
} else {
// Groupmode = separate groups but user is not member of any group
// and does not have moodle/site:accessallgroups capability -> refuse view responses.
- if (!$questionnaire->canviewallgroups) {
+ if (!$questionnaire->capabilities()->can_view_all_groups()) {
$currentgroupid = 0;
}
}
@@ -239,15 +197,17 @@
$compare = false;
// Print the page header.
- echo $questionnaire->renderer->header();
+ echo $renderer->header();
// Print the tabs.
- include('tabs.php');
- $questionnaire->page->add_to_page('myheaders', $titletext);
+ $myrid = is_int($rid) ? $rid : null;
+ (new \mod_questionnaire\output\tabs($questionnaire, 'mybyresponse', $currentgroupid, $myrid))
+ ->render($page);
+ $page->add_to_page('myheaders', $titletext);
if (count($resps) > 1) {
$userresps = $resps;
- $questionnaire->survey_results_navbar_student($rid, $userid, $instance, $userresps);
+ $questionnaire->reporter($renderer, $page)->survey_results_navbar_student($rid, $userid, $instance, $userresps);
}
$resps = [];
// Determine here which "global" responses should get displayed for comparison with current user.
@@ -266,10 +226,11 @@
$resps = $respsallparticipants;
}
$compare = true;
- $questionnaire->view_response($rid, null, $resps, $compare, $iscurrentgroupmember, false, $currentgroupid);
+ $questionnaire->reporter($renderer, $page)
+ ->view_response($rid, '', $resps, $compare, $iscurrentgroupmember, false, $currentgroupid);
// Finish the page.
- echo $questionnaire->renderer->render($questionnaire->page);
- echo $questionnaire->renderer->footer($course);
+ echo $renderer->render($page);
+ echo $renderer->footer($course);
break;
case get_string('return', 'questionnaire'):
diff --git a/preview.php b/preview.php
index 378733cdd..e81940a8e 100644
--- a/preview.php
+++ b/preview.php
@@ -24,7 +24,8 @@
*/
require_once("../../config.php");
-require_once($CFG->dirroot . '/mod/questionnaire/questionnaire.class.php');
+
+use mod_questionnaire\questionnaire as questionnaire_class;
$id = optional_param('id', 0, PARAM_INT);
$sid = optional_param('sid', 0, PARAM_INT);
@@ -33,44 +34,33 @@
$currentgroupid = optional_param('group', 0, PARAM_INT); // Groupid.
if ($id) {
- if (! $cm = get_coursemodule_from_id('questionnaire', $id)) {
- throw new \moodle_exception('invalidcoursemodule', 'mod_questionnaire');
- }
-
- if (! $course = $DB->get_record("course", ["id" => $cm->course])) {
- throw new \moodle_exception('coursemisconf', 'mod_questionnaire');
- }
-
- if (!$questionnaire = $DB->get_record("questionnaire", ["id" => $cm->instance])) {
- throw new \moodle_exception('invalidcoursemodule', 'mod_questionnaire');
- }
+ // Normal module instance.
+ $questionnaire = questionnaire_class::from_cmid($id);
+ $course = $questionnaire->course();
+ $cm = $questionnaire->coursemodule();
} else {
- if (!$survey = $DB->get_record("questionnaire_survey", ["id" => $sid])) {
+ // Survey-only (template/public preview from "Add questionnaire" page).
+ if (!$survey = $DB->get_record('questionnaire_survey', ['id' => $sid])) {
throw new \moodle_exception('surveynotexists', 'mod_questionnaire');
}
- if (! $course = $DB->get_record("course", ["id" => $survey->courseid])) {
+ if (!$course = $DB->get_record('course', ['id' => $survey->courseid])) {
throw new \moodle_exception('coursemisconf', 'mod_questionnaire');
}
- // Dummy questionnaire object.
- $questionnaire = new stdClass();
- $questionnaire->id = 0;
- $questionnaire->course = $course->id;
- $questionnaire->name = $survey->title;
- $questionnaire->sid = $sid;
- $questionnaire->resume = 0;
- // Dummy cm object.
- if (!empty($qid)) {
- $cm = get_coursemodule_from_instance('questionnaire', $qid, $course->id);
+ $cm = !empty($qid) ? get_coursemodule_from_instance('questionnaire', $qid, $course->id) : false;
+ if ($cm) {
+ $questionnaire = questionnaire_class::from_cmid($cm->id);
} else {
- $cm = false;
+ $questionnaire = questionnaire_class::from_survey($sid, $course);
}
}
+$canpreview = $questionnaire->capabilities()->can_preview();
+$canprintblank = $questionnaire->capabilities()->can_print_blank();
// Check login and get context.
// Do not require login if this questionnaire is viewed from the Add questionnaire page
// to enable teachers to view template or public questionnaires located in a course where they are not enroled.
if (!$popup) {
- require_login($course->id, false, $cm);
+ require_login($course->id, false, $cm ?: null);
}
$context = $cm ? context_module::instance($cm->id) : false;
@@ -82,30 +72,16 @@
$url->param('sid', $sid);
}
$PAGE->set_url($url);
+$PAGE->set_context($context ?: context_course::instance($course->id));
+if ($cm) {
+ $PAGE->set_cm($cm);
+}
-$PAGE->set_context($context);
-$PAGE->set_cm($cm); // CONTRIB-5872 - I don't know why this is needed.
-
-$questionnaire = new questionnaire($course, $cm, $qid, $questionnaire);
-
-// Add renderer and page objects to the questionnaire object for display use.
-$questionnaire->add_renderer($PAGE->get_renderer('mod_questionnaire'));
-$questionnaire->add_page(new \mod_questionnaire\output\previewpage());
-
-$canpreview = (!isset($questionnaire->capabilities) &&
- has_capability('mod/questionnaire:preview', context_course::instance($course->id))) ||
- (isset($questionnaire->capabilities) && $questionnaire->capabilities->preview);
if (!$canpreview && !$popup) {
// Should never happen, unless called directly by a snoop...
throw new \moodle_exception('nopermissions', 'mod_questionnaire');
}
-if (!isset($SESSION->questionnaire)) {
- $SESSION->questionnaire = new stdClass();
-}
-$SESSION->questionnaire->current_tab = new stdClass();
-$SESSION->questionnaire->current_tab = 'preview';
-
$qp = get_string('preview_questionnaire', 'questionnaire');
$pq = get_string('previewing', 'questionnaire');
@@ -118,26 +94,21 @@
$PAGE->set_heading(format_string($course->fullname));
}
-// Include the needed js.
-
-
-$PAGE->requires->js('/mod/questionnaire/module.js');
-// Print the tabs.
-
+$renderer = $PAGE->get_renderer('mod_questionnaire');
+$page = new \mod_questionnaire\output\previewpage();
-echo $questionnaire->renderer->header();
+echo $renderer->header();
if (!$popup) {
- require('tabs.php');
+ (new \mod_questionnaire\output\tabs($questionnaire, 'preview'))->render($page);
}
-$questionnaire->page->add_to_page('heading', clean_text($pq));
+$page->add_to_page('heading', clean_text($pq));
-if ($questionnaire->capabilities->printblank) {
+if ($canprintblank) {
// Open print friendly as popup window.
-
$linkname = ' ' . get_string('printblank', 'questionnaire');
$title = get_string('printblanktooltip', 'questionnaire');
- $url = '/mod/questionnaire/print.php?qid=' . $questionnaire->id . '&rid=0&' . 'courseid=' .
- $questionnaire->course->id . '&sec=1';
+ $url = '/mod/questionnaire/print.php?qid=' . $questionnaire->id() . '&rid=0&' . 'courseid=' .
+ $course->id . '&sec=1';
$options = [
'menubar' => true,
'location' => false,
@@ -151,9 +122,9 @@
$link = new moodle_url($url);
$action = new popup_action('click', $link, $name, $options);
$class = "floatprinticon";
- $questionnaire->page->add_to_page(
+ $page->add_to_page(
'printblank',
- $questionnaire->renderer->action_link(
+ $renderer->action_link(
$link,
$linkname,
$action,
@@ -162,20 +133,22 @@
)
);
}
-$questionnaire->survey_print_render($course->id, '', 'preview', $rid = 0, $popup);
+
+(new \mod_questionnaire\local\report\report_view_builder($renderer, $page))
+ ->build_print_view($questionnaire, $course->id, '', 'preview', 0, $popup);
if ($popup) {
- $questionnaire->page->add_to_page('closebutton', $questionnaire->renderer->close_window_button());
+ $page->add_to_page('closebutton', $renderer->close_window_button());
+}
+echo $renderer->render($page);
+echo $renderer->footer($course);
+
+// Log this questionnaire preview (skip in survey-only mode — no real module instance).
+if ($questionnaire->id() > 0) {
+ $anonymous = $questionnaire->respondenttype() == 'anonymous';
+ $event = \mod_questionnaire\event\questionnaire_previewed::create([
+ 'objectid' => $questionnaire->id(),
+ 'anonymous' => $anonymous,
+ 'context' => $questionnaire->context(),
+ ]);
+ $event->trigger();
}
-echo $questionnaire->renderer->render($questionnaire->page);
-echo $questionnaire->renderer->footer($course);
-
-// Log this questionnaire preview.
-$context = context_module::instance($questionnaire->cm->id);
-$anonymous = $questionnaire->respondenttype == 'anonymous';
-
-$event = \mod_questionnaire\event\questionnaire_previewed::create([
- 'objectid' => $questionnaire->id,
- 'anonymous' => $anonymous,
- 'context' => $context,
-]);
-$event->trigger();
diff --git a/print.php b/print.php
index 5f8798137..82b8acfa7 100644
--- a/print.php
+++ b/print.php
@@ -25,57 +25,43 @@
*/
require_once("../../config.php");
-require_once($CFG->dirroot . '/mod/questionnaire/questionnaire.class.php');
+
+use mod_questionnaire\questionnaire;
$qid = required_param('qid', PARAM_INT);
$rid = required_param('rid', PARAM_INT);
$courseid = required_param('courseid', PARAM_INT);
$sec = required_param('sec', PARAM_INT);
-$null = null;
-$referer = $CFG->wwwroot . '/mod/questionnaire/report.php';
+$referer = '/mod/questionnaire/report.php';
-if (! $questionnaire = $DB->get_record("questionnaire", ["id" => $qid])) {
- throw new \moodle_exception('invalidcoursemodule', 'mod_questionnaire');
-}
-if (! $course = $DB->get_record("course", ["id" => $questionnaire->course])) {
- throw new \moodle_exception('coursemisconf', 'mod_questionnaire');
-}
-if (! $cm = get_coursemodule_from_instance("questionnaire", $questionnaire->id, $course->id)) {
- throw new \moodle_exception('invalidcoursemodule', 'mod_questionnaire');
-}
+$questionnaire = questionnaire::from_instanceid($qid);
// Check login and get context.
require_login($courseid);
-$questionnaire = new questionnaire($course, $cm, 0, $questionnaire);
-
-// Add renderer and page objects to the questionnaire object for display use.
-$questionnaire->add_renderer($PAGE->get_renderer('mod_questionnaire'));
-if (!empty($rid)) {
- $questionnaire->add_page(new \mod_questionnaire\output\reportpage());
-} else {
- $questionnaire->add_page(new \mod_questionnaire\output\previewpage());
-}
+$renderer = $PAGE->get_renderer('mod_questionnaire');
+$page = !empty($rid)
+ ? new \mod_questionnaire\output\reportpage()
+ : new \mod_questionnaire\output\previewpage();
// If you can't view the questionnaire, or can't view a specified response, error out.
-if (!($questionnaire->capabilities->view && (($rid == 0) || $questionnaire->can_view_response($rid)))) {
+if (!($questionnaire->capabilities()->can_view() && (($rid == 0) || $questionnaire->capabilities()->can_view_response($rid)))) {
// Should never happen, unless called directly by a snoop...
throw new \moodle_exception('nopermissions', 'mod_questionnaire');
}
-$blankquestionnaire = true;
-if ($rid != 0) {
- $blankquestionnaire = false;
-}
-$url = new moodle_url($CFG->wwwroot . '/mod/questionnaire/print.php');
+$blankquestionnaire = ($rid == 0);
+
+$url = new moodle_url('/mod/questionnaire/print.php');
$url->param('qid', $qid);
$url->param('rid', $rid);
$url->param('courseid', $courseid);
$url->param('sec', $sec);
$PAGE->set_url($url);
-$PAGE->set_title($questionnaire->survey->title);
+$PAGE->set_title($questionnaire->surveytitle());
$PAGE->set_pagelayout('popup');
-echo $questionnaire->renderer->header();
-$questionnaire->page->add_to_page('closebutton', $questionnaire->renderer->close_window_button());
-$questionnaire->survey_print_render($courseid, '', 'print', $rid, $blankquestionnaire);
-echo $questionnaire->renderer->render($questionnaire->page);
-echo $questionnaire->renderer->footer();
+echo $renderer->header();
+$page->add_to_page('closebutton', $renderer->close_window_button());
+(new \mod_questionnaire\local\report\report_view_builder($renderer, $page))
+ ->build_print_view($questionnaire, $courseid, '', 'print', $rid, $blankquestionnaire);
+echo $renderer->render($page);
+echo $renderer->footer();
diff --git a/qsettings.php b/qsettings.php
index 9d31de77d..5941bdb9e 100644
--- a/qsettings.php
+++ b/qsettings.php
@@ -23,25 +23,25 @@
*/
require_once("../../config.php");
-require_once($CFG->dirroot . '/mod/questionnaire/questionnaire.class.php');
+
+use mod_questionnaire\questionnaire;
+use mod_questionnaire\survey_settings_controller;
+use mod_questionnaire\output\qsettingspage;
$id = required_param('id', PARAM_INT); // Course module ID.
$currentgroupid = optional_param('group', 0, PARAM_INT); // Groupid.
$cancel = optional_param('cancel', '', PARAM_ALPHA);
$submitbutton2 = optional_param('submitbutton2', '', PARAM_ALPHA);
-if (! $cm = get_coursemodule_from_id('questionnaire', $id)) {
+if (! $cmrecord = get_coursemodule_from_id('questionnaire', $id)) {
throw new \moodle_exception('invalidcoursemodule', 'mod_questionnaire');
}
+$cm = \cm_info::create($cmrecord);
if (! $course = $DB->get_record("course", ["id" => $cm->course])) {
throw new \moodle_exception('coursemisconf', 'mod_questionnaire');
}
-if (! $questionnaire = $DB->get_record("questionnaire", ["id" => $cm->instance])) {
- throw new \moodle_exception('invalidcoursemodule', 'mod_questionnaire');
-}
-
// Needed here for forced language courses.
require_course_login($course, true, $cm);
$context = context_module::instance($cm->id);
@@ -49,24 +49,18 @@
$url = new moodle_url($CFG->wwwroot . '/mod/questionnaire/qsettings.php', ['id' => $id]);
$PAGE->set_url($url);
$PAGE->set_context($context);
-if (!isset($SESSION->questionnaire)) {
- $SESSION->questionnaire = new stdClass();
-}
-$questionnaire = new questionnaire($course, $cm, 0, $questionnaire);
+$questionnaire = questionnaire::from_cm($cm);
-// Add renderer and page objects to the questionnaire object for display use.
-$questionnaire->add_renderer($PAGE->get_renderer('mod_questionnaire'));
-$questionnaire->add_page(new \mod_questionnaire\output\qsettingspage());
+$renderer = $PAGE->get_renderer('mod_questionnaire');
+$page = new qsettingspage();
-$SESSION->questionnaire->current_tab = 'settings';
-
-if (!$questionnaire->capabilities->manage) {
+if (!$questionnaire->capabilities()->can_manage_questionnaire()) {
throw new \moodle_exception('nopermissions', 'mod_questionnaire');
}
$settingsform = new \mod_questionnaire\settings_form('qsettings.php');
-$sdata = clone($questionnaire->survey);
-$sdata->sid = $questionnaire->survey->id;
+$sdata = $questionnaire->survey()->to_stdclass();
+$sdata->sid = $questionnaire->surveyid();
$sdata->id = $cm->id;
$draftideditor = file_get_submitted_draft_itemid('info');
@@ -77,7 +71,7 @@
'info',
$sdata->sid,
['subdirs' => true],
- $questionnaire->survey->info
+ $questionnaire->survey()->info()
);
$sdata->info = ['text' => $currentinfo, 'format' => FORMAT_HTML, 'itemid' => $draftideditor];
@@ -89,69 +83,28 @@
'thankbody',
$sdata->sid,
['subdirs' => true],
- $questionnaire->survey->thank_body
+ $questionnaire->survey()->thankbody()
);
-$sdata->thank_body = ['text' => $currentinfo, 'format' => FORMAT_HTML, 'itemid' => $draftideditor];
+$sdata->thankbody = ['text' => $currentinfo, 'format' => FORMAT_HTML, 'itemid' => $draftideditor];
$settingsform->set_data($sdata);
if ($settingsform->is_cancelled()) {
- redirect($CFG->wwwroot . '/mod/questionnaire/view.php?id=' . $questionnaire->cm->id, '');
+ redirect($CFG->wwwroot . '/mod/questionnaire/view.php?id=' . $questionnaire->coursemodule()->id, '');
}
if ($settings = $settingsform->get_data()) {
- $sdata = new stdClass();
- $sdata->id = $settings->sid;
- $sdata->name = $settings->name;
- $sdata->realm = $settings->realm;
- $sdata->title = $settings->title;
- $sdata->subtitle = $settings->subtitle;
-
- $sdata->infoitemid = $settings->info['itemid'];
- $sdata->infoformat = $settings->info['format'];
- $sdata->info = $settings->info['text'];
- $sdata->info = file_save_draft_area_files(
- $sdata->infoitemid,
- $context->id,
- 'mod_questionnaire',
- 'info',
- $sdata->id,
- ['subdirs' => true],
- $sdata->info
- );
-
- $sdata->theme = ''; // Deprecated theme field.
- $sdata->thanks_page = $settings->thanks_page;
- $sdata->thank_head = $settings->thank_head;
-
- $sdata->thankitemid = $settings->thank_body['itemid'];
- $sdata->thankformat = $settings->thank_body['format'];
- $sdata->thank_body = $settings->thank_body['text'];
- $sdata->thank_body = file_save_draft_area_files(
- $sdata->thankitemid,
- $context->id,
- 'mod_questionnaire',
- 'thankbody',
- $sdata->id,
- ['subdirs' => true],
- $sdata->thank_body
- );
- $sdata->email = $settings->email;
-
- $sdata->courseid = $settings->courseid;
- if (!($sid = $questionnaire->survey_update($sdata))) {
- throw new \moodle_exception('couldnotcreatenewsurvey', 'mod_questionnaire');
+ (new survey_settings_controller($questionnaire))->save($settings);
+
+ if ($submitbutton2) {
+ $redirecturl = course_get_url($cm->course);
} else {
- if ($submitbutton2) {
- $redirecturl = course_get_url($cm->course);
- } else {
- $redirecturl = $CFG->wwwroot . '/mod/questionnaire/view.php?id=' . $questionnaire->cm->id;
- }
-
- // Save current advanced settings only.
- if (isset($settings->submitbutton) || isset($settings->submitbutton2)) {
- redirect($redirecturl, get_string('settingssaved', 'questionnaire'));
- }
+ $redirecturl = $CFG->wwwroot . '/mod/questionnaire/view.php?id=' . $questionnaire->coursemodule()->id;
+ }
+
+ // Save current advanced settings only.
+ if (isset($settings->submitbutton) || isset($settings->submitbutton2)) {
+ redirect($redirecturl, get_string('settingssaved', 'questionnaire'));
}
}
@@ -159,8 +112,8 @@
$PAGE->set_title(get_string('editingquestionnaire', 'questionnaire'));
$PAGE->set_heading(format_string($course->fullname));
$PAGE->navbar->add(get_string('editingquestionnaire', 'questionnaire'));
-echo $questionnaire->renderer->header();
-require('tabs.php');
-$questionnaire->page->add_to_page('formarea', $settingsform->render());
-echo $questionnaire->renderer->render($questionnaire->page);
-echo $questionnaire->renderer->footer($course);
+echo $renderer->header();
+(new \mod_questionnaire\output\tabs($questionnaire, 'settings'))->render($page);
+$page->add_to_page('formarea', $settingsform->render());
+echo $renderer->render($page);
+echo $renderer->footer($course);
diff --git a/questionnaire.class.php b/questionnaire.class.php
deleted file mode 100644
index f95730748..000000000
--- a/questionnaire.class.php
+++ /dev/null
@@ -1,4605 +0,0 @@
-.
-
-use mod_questionnaire\feedback\section;
-
-defined('MOODLE_INTERNAL') || die();
-
-require_once($CFG->dirroot . '/mod/questionnaire/locallib.php');
-
-#[\AllowDynamicProperties]
-/**
- * Provided the main API functions for questionnaire.
- *
- * @package mod_questionnaire
- * @copyright 2016 Mike Churchward (mike.churchward@poetgroup.org)
- * @author Mike Churchward
- * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
- */
-class questionnaire {
- // Class Properties.
-
- /**
- * @var \mod_questionnaire\question\question[] $quesitons
- */
- public $questions = [];
-
- /**
- * @var \mod_questionnaire\question\question[] $deletequestions
- */
- public $deletequestions = [];
-
- /**
- * The survey record.
- * @var object $survey
- */
- // Todo var $survey; TODO.
-
- /**
- * @var $renderer Contains the page renderer when loaded, or false if not.
- */
- public $renderer = false;
-
- /**
- * @var $page Contains the renderable, templatable page when loaded, or false if not.
- */
- public $page = false;
-
- // Class Methods.
-
- /**
- * The constructor.
- * @param stdClass $course
- * @param cm_info $cm
- * @param int $id
- * @param null|stdClass $questionnaire
- * @param bool $addquestions
- * @throws dml_exception
- */
- public function __construct(&$course, &$cm, $id = 0, $questionnaire = null, $addquestions = true) {
- global $DB;
-
- if ($id) {
- $questionnaire = $DB->get_record('questionnaire', ['id' => $id]);
- }
-
- if (is_object($questionnaire)) {
- $properties = get_object_vars($questionnaire);
- foreach ($properties as $property => $value) {
- $this->$property = $value;
- }
- }
-
- if (!empty($this->sid)) {
- $this->add_survey($this->sid);
- }
-
- $this->course = $course;
- $this->cm = $cm;
- // When we are creating a brand new questionnaire, we will not yet have a context.
- if (!empty($cm) && !empty($this->id)) {
- $this->context = context_module::instance($cm->id);
- } else {
- $this->context = null;
- }
-
- if ($addquestions && !empty($this->sid)) {
- $this->add_questions($this->sid);
- }
-
- // Load the capabilities for this user and questionnaire, if not creating a new one.
- if (!empty($this->cm->id)) {
- $this->capabilities = questionnaire_load_capabilities($this->cm->id);
- }
-
- // Don't automatically add responses.
- $this->responses = [];
- }
-
- /**
- * Get all delete questions by survey id.
- *
- * @return void
- * @throws dml_exception
- */
- public function get_delete_questions() {
- global $DB;
- $sql = "SELECT *
- FROM {questionnaire_question}
- WHERE deleted IS NOT NULL
- AND surveyid = ? AND type_id != ?
- ORDER BY deleted DESC";
- if ($records = $DB->get_records_sql($sql, [$this->sid, QUESPAGEBREAK])) {
- foreach ($records as $record) {
- $this->deletequestions[$record->id] = \mod_questionnaire\question\question::question_builder(
- $record->type_id,
- $record,
- $this->context
- );
- }
- }
- }
-
- /**
- * Adding a survey record to the object.
- * @param int $sid
- * @param null $survey
- */
- public function add_survey($sid = 0, $survey = null) {
- global $DB;
-
- if ($sid) {
- $this->survey = $DB->get_record('questionnaire_survey', ['id' => $sid]);
- } else if (is_object($survey)) {
- $this->survey = clone($survey);
- }
- }
-
- /**
- * Adding questions to the object.
- * @param bool $sid
- */
- public function add_questions($sid = 0) {
- global $DB;
-
- if ($sid === 0) {
- $sid = $this->sid;
- }
-
- if (!isset($this->questions)) {
- $this->questions = [];
- $this->questionsbysec = [];
- }
-
- $select = 'surveyid = ? AND deleted IS NULL';
- if ($records = $DB->get_records_select('questionnaire_question', $select, [$sid], 'position')) {
- $sec = 1;
- $isbreak = false;
- foreach ($records as $record) {
- $this->questions[$record->id] = \mod_questionnaire\question\question::question_builder(
- $record->type_id,
- $record,
- $this->context
- );
-
- if ($record->type_id != QUESPAGEBREAK) {
- $this->questionsbysec[$sec][] = $record->id;
- $isbreak = false;
- } else {
- // Sanity check: no section break allowed as first position, no 2 consecutive section breaks.
- if ($record->position != 1 && $isbreak == false) {
- $sec++;
- $isbreak = true;
- }
- }
- }
- }
- }
-
- /**
- * Load all response information for this user.
- *
- * @param int $userid
- */
- public function add_user_responses($userid = null) {
- global $USER, $DB;
-
- // Empty questionnaires cannot have responses.
- if (empty($this->id)) {
- return;
- }
-
- if ($userid === null) {
- $userid = $USER->id;
- }
-
- $responses = $this->get_responses($userid);
- foreach ($responses as $response) {
- $this->responses[$response->id] = mod_questionnaire\responsetype\response\response::create_from_data($response);
- }
- }
-
- /**
- * Load the specified response information.
- *
- * @param int $responseid
- */
- public function add_response(int $responseid) {
- global $DB;
-
- // Empty questionnaires cannot have responses.
- if (empty($this->id)) {
- return;
- }
-
- $response = $DB->get_record('questionnaire_response', ['id' => $responseid]);
- $this->responses[$response->id] = mod_questionnaire\responsetype\response\response::create_from_data($response);
- }
-
- /**
- * 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\responsetype\response\response::response_from_webform($formdata, $this->questions);
- }
-
- /**
- * Return a response object from a submitted mobile app form.
- *
- * @param stdClass $appdata
- * @param int $sec
- * @return bool|\mod_questionnaire\responsetype\response\response
- */
- public function build_response_from_appdata(stdClass $appdata, $sec = 0) {
- $questions = [];
- if ($sec == 0) {
- $questions = $this->questions;
- } else {
- foreach ($this->questionsbysec[$sec] as $questionid) {
- $questions[$questionid] = $this->questions[$questionid];
- }
- }
- return mod_questionnaire\responsetype\response\response::response_from_appdata($this->id, 0, $appdata, $questions);
- }
-
- /**
- * Add the renderer to the questionnaire object.
- * @param plugin_renderer_base $renderer The module renderer, extended from core renderer.
- */
- public function add_renderer(plugin_renderer_base $renderer) {
- $this->renderer = $renderer;
- }
-
- /**
- * Add the templatable page to the questionnaire object.
- * @param templatable $page The page to render, implementing core classes.
- */
- public function add_page($page) {
- $this->page = $page;
- }
-
- /**
- * Return true if questions should be automatically numbered.
- * @return bool
- */
- public function questions_autonumbered() {
- // Value of 1 if questions should be numbered. Value of 3 if both questions and pages should be numbered.
- return (!empty($this->autonum) && (($this->autonum == 1) || ($this->autonum == 3)));
- }
-
- /**
- * Return true if pages should be automatically numbered.
- * @return bool
- */
- public function pages_autonumbered() {
- // Value of 2 if pages should be numbered. Value of 3 if both questions and pages should be numbered.
- return (!empty($this->autonum) && (($this->autonum == 2) || ($this->autonum == 3)));
- }
-
- /**
- * The main module view function.
- */
- public function view() {
- global $CFG, $USER, $PAGE;
-
- $PAGE->set_title(format_string($this->name));
- $PAGE->set_heading(format_string($this->course->fullname));
- $message = $this->user_access_messages($USER->id, true);
- if ($message !== false) {
- $this->page->add_to_page('notifications', $message);
- } else {
- // Handle the main questionnaire completion page.
- $quser = $USER->id;
-
- $msg = $this->print_survey($quser, $USER->id);
-
- // If Questionnaire was submitted with all required fields completed ($msg is empty),
- // then record the submittal.
- $viewform = data_submitted($CFG->wwwroot . "/mod/questionnaire/complete.php");
- 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;
- }
- $this->response_delete($viewform->rid, $viewform->sec);
- $this->rid = $this->response_insert($viewform, $quser);
- $this->response_commit($this->rid);
-
- $this->update_grades($quser);
-
- // Update completion state.
- $completion = new completion_info($this->course);
- if ($completion->is_enabled($this->cm) && $this->completionsubmit) {
- $completion->update_state($this->cm, COMPLETION_COMPLETE);
- }
-
- // Log this submitted response. Note this removes the anonymity in the logged event.
- $context = context_module::instance($this->cm->id);
- $anonymous = $this->respondenttype == 'anonymous';
- $params = [
- 'context' => $context,
- 'courseid' => $this->course->id,
- 'relateduserid' => $USER->id,
- 'anonymous' => $anonymous,
- 'other' => ['questionnaireid' => $this->id],
- ];
- $event = \mod_questionnaire\event\attempt_submitted::create($params);
- $event->trigger();
-
- $this->submission_notify($this->rid);
- $this->response_goto_thankyou();
- }
- }
- }
-
- /**
- * Delete the specified response, and insert a new one.
- * @param int $rid
- * @param int $sec
- * @param int $quser
- * @return bool|int
- */
- public function delete_insert_response($rid, $sec, $quser) {
- $this->response_delete($rid, $sec);
- $this->rid = $this->response_insert((object)['sec' => $sec, 'rid' => $rid], $quser);
- return $this->rid;
- }
-
- /**
- * Commit the response.
- * @param int $rid
- * @param int $quser
- */
- public function commit_submission_response($rid, $quser) {
- $this->response_commit($rid);
- // If it was a previous save, rid is in the form...
- if (!empty($rid) && is_numeric($rid)) {
- $rid = $rid;
- // Otherwise its in this object.
- } else {
- $rid = $this->rid;
- }
-
- $this->update_grades($quser);
-
- // Update completion state.
- $completion = new \completion_info($this->course);
- if ($completion->is_enabled($this->cm) && $this->completionsubmit) {
- $completion->update_state($this->cm, COMPLETION_COMPLETE);
- }
- // Log this submitted response.
- $context = \context_module::instance($this->cm->id);
- $anonymous = $this->respondenttype == 'anonymous';
- $params = [
- 'context' => $context,
- 'courseid' => $this->course->id,
- 'relateduserid' => $quser,
- 'anonymous' => $anonymous,
- 'other' => ['questionnaireid' => $this->id],
- ];
- $event = \mod_questionnaire\event\attempt_submitted::create($params);
- $event->trigger();
- }
-
- /**
- * Update the grade for this questionnaire and user.
- *
- * @param int $userid
- */
- private function update_grades($userid) {
- if ($this->grade != 0) {
- $questionnaire = new \stdClass();
- $questionnaire->id = $this->id;
- $questionnaire->name = $this->name;
- $questionnaire->grade = $this->grade;
- $questionnaire->cmidnumber = $this->cm->idnumber;
- $questionnaire->courseid = $this->course->id;
- questionnaire_update_grades($questionnaire, $userid);
- }
- }
-
- /**
- * Function to view an entire responses data.
- * @param int $rid
- * @param string $referer
- * @param string $resps
- * @param bool $compare
- * @param bool $isgroupmember
- * @param bool $allresponses
- * @param int $currentgroupid
- * @param string $outputtarget
- */
- public function view_response(
- $rid,
- $referer = '',
- $resps = '',
- $compare = false,
- $isgroupmember = false,
- $allresponses = false,
- $currentgroupid = 0,
- $outputtarget = 'html'
- ) {
- if (!$this->can_view_response($rid)) {
- throw new \moodle_exception('nopermissions', 'mod_questionnaire');
- }
-
- $this->print_survey_start('', 1, 1, 0, $rid, false, $outputtarget);
-
- $i = 0;
- $this->add_response($rid);
- if ($referer != 'print') {
- $feedbackmessages = $this->response_analysis($rid, $resps, $compare, $isgroupmember, $allresponses, $currentgroupid);
-
- if ($feedbackmessages) {
- $msgout = '';
- foreach ($feedbackmessages as $msg) {
- $msgout .= $msg;
- }
- $this->page->add_to_page('feedbackmessages', $msgout);
- }
-
- if ($this->survey->feedbacknotes) {
- $text = file_rewrite_pluginfile_urls(
- $this->survey->feedbacknotes,
- 'pluginfile.php',
- $this->context->id,
- 'mod_questionnaire',
- 'feedbacknotes',
- $this->survey->id
- );
- $this->page->add_to_page('feedbacknotes', $this->renderer->box(format_text($text, FORMAT_HTML)));
- }
- }
- $pdf = ($outputtarget == 'pdf') ? true : false;
- foreach ($this->questions as $question) {
- // Only show eligible questions in the response.
- if (!$question->dependency_fulfilled($rid, $this->questions)) {
- continue;
- }
- if ($question->type_id < QUESPAGEBREAK) {
- $i++;
- }
- if ($question->type_id != QUESPAGEBREAK) {
- $this->page->add_to_page(
- 'responses',
- $this->renderer->response_output($question, $this->responses[$rid], $i, $pdf)
- );
- }
- }
- }
-
- /**
- * Function to view all loaded responses.
- */
- public function view_all_responses() {
- $this->print_survey_start('', 1, 1, 0);
-
- // If a student's responses have been deleted by teacher while student was viewing the report,
- // then responses may have become empty, hence this test is necessary.
-
- if (!empty($this->responses)) {
- $this->page->add_to_page('responses', $this->renderer->all_response_output($this->responses, $this->questions));
- } else {
- $this->page->add_to_page('responses', $this->renderer->all_response_output(get_string('noresponses', 'questionnaire')));
- }
-
- $this->print_survey_end(1, 1);
- }
-
- // Access Methods.
-
- /**
- * True if the questionnaire is active.
- * @return bool
- */
- public function is_active() {
- return (!empty($this->survey));
- }
-
- /**
- * True if the questionnaire is open.
- * @return bool
- */
- public function is_open() {
- return ($this->opendate > 0) ? ($this->opendate < time()) : true;
- }
-
- /**
- * True if the questionnaire is closed.
- * @return bool
- */
- public function is_closed() {
- return ($this->closedate > 0) ? ($this->closedate < time()) : false;
- }
-
- /**
- * True if the specified user can complete this questionnaire.
- * @param int $userid
- * @return bool
- */
- public function user_can_take($userid) {
-
- if (!$this->is_active() || !$this->user_is_eligible($userid)) {
- return false;
- } else if ($this->qtype == QUESTIONNAIREUNLIMITED) {
- return true;
- } else if ($userid > 0) {
- return $this->user_time_for_new_attempt($userid);
- } else {
- return false;
- }
- }
-
- /**
- * True if the specified user is eligible to complete this questionnaire.
- * @param int $userid
- * @return bool
- */
- public function user_is_eligible($userid) {
- return ($this->capabilities->view && $this->capabilities->submit);
- }
-
- /**
- * Return any message if the user cannot complete this questionnaire, explaining why.
- * @param int $userid
- * @param bool $asnotification Return as a rendered notification.
- * @return bool|string
- */
- public function user_access_messages($userid = 0, $asnotification = false) {
- global $USER;
-
- if ($userid == 0) {
- $userid = $USER->id;
- }
- $message = false;
-
- if (!$this->is_active()) {
- if ($this->capabilities->manage) {
- $msg = 'removenotinuse';
- } else {
- $msg = 'notavail';
- }
- $message = get_string($msg, 'questionnaire');
- } else if ($this->survey->realm == 'template') {
- $message = get_string('templatenotviewable', 'questionnaire');
- } else if (!$this->is_open()) {
- $message = get_string('notopen', 'questionnaire', userdate($this->opendate));
- } else if ($this->is_closed()) {
- $message = get_string('closed', 'questionnaire', userdate($this->closedate));
- } else if (!$this->user_is_eligible($userid)) {
- $message = get_string('noteligible', 'questionnaire');
- } else if (!$this->user_can_take($userid)) {
- switch ($this->qtype) {
- case QUESTIONNAIREDAILY:
- $msgstring = ' ' . get_string('today', 'questionnaire');
- break;
- case QUESTIONNAIREWEEKLY:
- $msgstring = ' ' . get_string('thisweek', 'questionnaire');
- break;
- case QUESTIONNAIREMONTHLY:
- $msgstring = ' ' . get_string('thismonth', 'questionnaire');
- break;
- default:
- $msgstring = '';
- break;
- }
- $message = get_string("alreadyfilled", "questionnaire", $msgstring);
- }
-
- if (($message !== false) && $asnotification) {
- $message = $this->renderer->notification($message, \core\output\notification::NOTIFY_ERROR);
- }
-
- return $message;
- }
-
- /**
- * True if the specified user has a saved response for this questionnaire.
- * @param int $userid
- * @return bool
- */
- public function user_has_saved_response($userid) {
- global $DB;
-
- return $DB->record_exists(
- 'questionnaire_response',
- ['questionnaireid' => $this->id, 'userid' => $userid, 'complete' => 'n']
- );
- }
-
- /**
- * True if the specified user can complete this questionnaire at this time.
- * @param int $userid
- * @return bool
- */
- public function user_time_for_new_attempt($userid) {
- global $DB;
-
- $params = ['questionnaireid' => $this->id, 'userid' => $userid, 'complete' => 'y'];
- if (!($attempts = $DB->get_records('questionnaire_response', $params, 'submitted DESC'))) {
- return true;
- }
-
- $attempt = reset($attempts);
- $timenow = time();
-
- switch ($this->qtype) {
- case QUESTIONNAIREUNLIMITED:
- $cantake = true;
- break;
-
- case QUESTIONNAIREONCE:
- $cantake = false;
- break;
-
- case QUESTIONNAIREDAILY:
- $attemptyear = date('Y', $attempt->submitted);
- $currentyear = date('Y', $timenow);
- $attemptdayofyear = date('z', $attempt->submitted);
- $currentdayofyear = date('z', $timenow);
- $cantake = (($attemptyear < $currentyear) ||
- (($attemptyear == $currentyear) && ($attemptdayofyear < $currentdayofyear)));
- break;
-
- case QUESTIONNAIREWEEKLY:
- $attemptyear = date('Y', $attempt->submitted);
- $currentyear = date('Y', $timenow);
- $attemptweekofyear = date('W', $attempt->submitted);
- $currentweekofyear = date('W', $timenow);
- $cantake = (($attemptyear < $currentyear) ||
- (($attemptyear == $currentyear) && ($attemptweekofyear < $currentweekofyear)));
- break;
-
- case QUESTIONNAIREMONTHLY:
- $attemptyear = date('Y', $attempt->submitted);
- $currentyear = date('Y', $timenow);
- $attemptmonthofyear = date('n', $attempt->submitted);
- $currentmonthofyear = date('n', $timenow);
- $cantake = (($attemptyear < $currentyear) ||
- (($attemptyear == $currentyear) && ($attemptmonthofyear < $currentmonthofyear)));
- break;
-
- default:
- $cantake = false;
- break;
- }
-
- return $cantake;
- }
-
- /**
- * True if the accessing course contains the actual questionnaire, as opposed to an instance of a public questionnaire.
- * @return bool
- */
- public function is_survey_owner() {
- return (!empty($this->survey->courseid) && ($this->course->id == $this->survey->courseid));
- }
-
- /**
- * True if the user can view the specified response.
- * @param int $rid
- * @return bool|void
- */
- public function can_view_response($rid) {
- global $USER, $DB;
-
- if (!empty($rid)) {
- $response = $DB->get_record('questionnaire_response', ['id' => $rid]);
-
- // If the response was not found, can't view it.
- if (empty($response)) {
- return false;
- }
-
- // If the response belongs to a different survey than this one, can't view it.
- if ($response->questionnaireid != $this->id) {
- return false;
- }
-
- // If you can view all responses always, then you can view it.
- if ($this->capabilities->readallresponseanytime) {
- return true;
- }
-
- // If you are allowed to view this response for another user.
- // If resp_view is set to QUESTIONNAIRE_STUDENTVIEWRESPONSES_NEVER, then this will always be false.
- if (
- $this->capabilities->readallresponses &&
- ($this->resp_view == QUESTIONNAIRE_STUDENTVIEWRESPONSES_ALWAYS ||
- ($this->resp_view == QUESTIONNAIRE_STUDENTVIEWRESPONSES_WHENCLOSED && $this->is_closed()) ||
- ($this->resp_view == QUESTIONNAIRE_STUDENTVIEWRESPONSES_WHENANSWERED && !$this->user_can_take($USER->id)))
- ) {
- return true;
- }
-
- // If you can read your own response.
- if (
- ($response->userid == $USER->id) && $this->capabilities->readownresponses &&
- ($this->count_submissions($USER->id) > 0)
- ) {
- return true;
- }
- } else {
- // If you can view all responses always, then you can view it.
- if ($this->capabilities->readallresponseanytime) {
- return true;
- }
-
- // If you are allowed to view this response for another user.
- // If resp_view is set to QUESTIONNAIRE_STUDENTVIEWRESPONSES_NEVER, then this will always be false.
- if (
- $this->capabilities->readallresponses &&
- ($this->resp_view == QUESTIONNAIRE_STUDENTVIEWRESPONSES_ALWAYS ||
- ($this->resp_view == QUESTIONNAIRE_STUDENTVIEWRESPONSES_WHENCLOSED && $this->is_closed()) ||
- ($this->resp_view == QUESTIONNAIRE_STUDENTVIEWRESPONSES_WHENANSWERED && !$this->user_can_take($USER->id)))
- ) {
- return true;
- }
-
- // If you can read your own response.
- if ($this->capabilities->readownresponses && ($this->count_submissions($USER->id) > 0)) {
- return true;
- }
- }
- }
-
- /**
- * True if the user can view the responses to this questionnaire, and there are valid responses.
- *
- * @param null|int $usernumresp
- * @param bool $isviewreport
- * @return bool
- */
- public function can_view_all_responses($usernumresp = null, $isviewreport = false) {
- global $USER, $SESSION;
-
- $owner = $this->is_survey_owner();
- $numresp = $this->count_submissions();
- if ($usernumresp === null) {
- $usernumresp = $this->count_submissions($USER->id);
- }
-
- // Number of Responses in currently selected group (or all participants etc.).
- if (isset($SESSION->questionnaire->numselectedresps)) {
- $numselectedresps = $SESSION->questionnaire->numselectedresps;
- } else {
- $numselectedresps = $numresp;
- }
-
- // If questionnaire is set to separate groups, prevent user who is not member of any group
- // to view All responses.
- $canviewgroups = true;
- $canviewallgroups = has_capability('moodle/site:accessallgroups', $this->context);
- $groupmode = groups_get_activity_groupmode($this->cm, $this->course);
- if ($groupmode == 1) {
- $canviewgroups = groups_has_membership($this->cm, $USER->id);
- }
-
- $grouplogic = $canviewgroups || $canviewallgroups;
- $respslogic = ($numresp > 0) && ($numselectedresps > 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 of the responses to this questionnaire any time, and there are valid responses.
- * @param bool $grouplogic
- * @param bool $respslogic
- * @return bool
- */
- public function can_view_all_responses_anytime($grouplogic = true, $respslogic = true) {
- // Can view if you are a valid group user, this is the owning course, and there are responses, and you have no
- // response view restrictions.
- return $grouplogic && $respslogic && $this->is_survey_owner() && $this->capabilities->readallresponseanytime;
- }
-
- /**
- * True if the user can view all of the responses to this questionnaire any time, and there are valid responses.
- * @param null|int $usernumresp
- * @param bool $grouplogic
- * @param bool $respslogic
- * @return bool
- */
- public function can_view_all_responses_with_restrictions($usernumresp, $grouplogic = true, $respslogic = true) {
- // Can view if you are a valid group user, this is the owning course, and there are responses, and you can view
- // subject to viewing settings..
- return $grouplogic && $respslogic && $this->is_survey_owner() &&
- ($this->capabilities->readallresponses &&
- ($this->resp_view == QUESTIONNAIRE_STUDENTVIEWRESPONSES_ALWAYS ||
- ($this->resp_view == QUESTIONNAIRE_STUDENTVIEWRESPONSES_WHENCLOSED && $this->is_closed()) ||
- ($this->resp_view == QUESTIONNAIRE_STUDENTVIEWRESPONSES_WHENANSWERED && $usernumresp)));
- }
-
- /**
- * Return the number of submissions for this questionnaire.
- * @param bool $userid
- * @param int $groupid
- * @return int
- */
- public function count_submissions($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;
- }
-
- // Since submission can be across questionnaires in the case of public questionnaires, need to check the realm.
- // Public questionnaires can have responses to multiple questionnaire instances.
- if ($this->survey_is_public_master()) {
- $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 ' .
- $groupsql .
- 'WHERE s.id = :surveyid AND r.complete = :status' . $groupcnd;
- $params['surveyid'] = $this->sid;
- $params['status'] = 'y';
- } else {
- $sql = 'SELECT COUNT(r.id) ' .
- 'FROM {questionnaire_response} r ' .
- $groupsql .
- 'WHERE r.questionnaireid = :questionnaireid AND r.complete = :status' . $groupcnd;
- $params['questionnaireid'] = $this->id;
- $params['status'] = 'y';
- }
- if ($userid) {
- $sql .= ' AND r.userid = :userid';
- $params['userid'] = $userid;
- }
- return $DB->count_records_sql($sql, $params);
- }
-
- /**
- * 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;
- }
-
- // Since submission can be across questionnaires in the case of public questionnaires, need to check the realm.
- // Public questionnaires can have responses to multiple questionnaire instances.
- if ($this->survey_is_public_master()) {
- $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->sid;
- $params['status'] = 'y';
- } else {
- $sql = 'SELECT r.* ' .
- 'FROM {questionnaire_response} r ' .
- $groupsql .
- 'WHERE r.questionnaireid = :questionnaireid' . $groupcnd;
- $params['questionnaireid'] = $this->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 any of the questions are required.
- * @param int $section
- * @return bool
- */
- private function has_required($section = 0) {
- if (empty($this->questions)) {
- return false;
- } else if ($section <= 0) {
- foreach ($this->questions as $question) {
- if ($question->required()) {
- return true;
- }
- }
- } else if (key_exists($section, $this->questionsbysec)) {
- foreach ($this->questionsbysec[$section] as $questionid) {
- if ($this->questions[$questionid]->required()) {
- return true;
- }
- }
- }
- return false;
- }
-
- /**
- * Check if current questionnaire has dependencies set and any question has dependencies.
- *
- * @return boolean Whether dependencies are set or not.
- */
- public function has_dependencies() {
- $hasdependencies = false;
- if (($this->navigate > 0) && isset($this->questions) && !empty($this->questions)) {
- foreach ($this->questions as $question) {
- if ($question->has_dependencies()) {
- $hasdependencies = true;
- break;
- }
- }
- }
- return $hasdependencies;
- }
-
- /**
- * Get a list of all dependent questions.
- * @param int $questionid
- * @return array
- */
- public function get_all_dependants($questionid) {
- $directids = $this->get_dependants($questionid);
- $directs = [];
- $indirects = [];
- foreach ($directids as $directid) {
- $this->load_parents($this->questions[$directid]);
- $indirectids = $this->get_dependants($directid);
- foreach ($this->questions[$directid]->dependencies as $dep) {
- if ($dep->dependquestionid == $questionid) {
- $directs[$directid][] = $dep;
- }
- }
- foreach ($indirectids as $indirectid) {
- $this->load_parents($this->questions[$indirectid]);
- foreach ($this->questions[$indirectid]->dependencies as $dep) {
- if ($dep->dependquestionid != $questionid) {
- $indirects[$indirectid][] = $dep;
- }
- }
- }
- }
- $alldependants = new stdClass();
- $alldependants->directs = $directs;
- $alldependants->indirects = $indirects;
- return($alldependants);
- }
-
- /**
- * Get a list of all dependent questions.
- * @param int $questionid
- * @return array
- */
- public function get_dependants($questionid) {
- $qu = [];
- // Create an array which shows for every question the child-IDs.
- foreach ($this->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);
- }
-
- /**
- * Function to sort descendants array in get_dependants function.
- * @param mixed $a
- * @param mixed $b
- * @return int
- */
- private static function cmp($a, $b) {
- if ($a == $b) {
- return 0;
- } else if ($a < $b) {
- return -1;
- } else {
- return 1;
- }
- }
-
- /**
- * Get all descendants and choices for questions with descendants.
- * @return array
- */
- public function get_dependants_and_choices() {
- $questions = array_reverse($this->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 needed parent question information into the dependencies structure for the requested question.
- * @param \mod_questionnaire\question\question $question
- * @return bool
- */
- public function load_parents($question) {
- foreach ($question->dependencies as $did => $dependency) {
- $dependquestion = $this->questions[$dependency->dependquestionid];
- $qdependchoice = '';
- switch ($dependquestion->type_id) {
- case QUESRADIO:
- case QUESDROP:
- case QUESCHECK:
- $qdependchoice = $dependency->dependchoiceid;
- $dependchoice = $dependquestion->choices[$dependency->dependchoiceid]->content;
-
- $contents = questionnaire_choice_values($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;
- }
- break;
- }
- // Qdependquestion, parenttype and qdependchoice fields to be used in preview mode.
- $question->dependencies[$did]->qdependquestion = 'q' . $dependquestion->id;
- $question->dependencies[$did]->qdependchoice = $qdependchoice;
- $question->dependencies[$did]->parenttype = $dependquestion->type_id;
- // Other fields to be used in Questions edit mode.
- $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;
- }
-
- /**
- * Determine the next valid page and return it. Return false if no valid next page.
- * @param int $secnum
- * @param int $rid
- * @return int | bool
- */
- public function next_page($secnum, $rid) {
- $secnum++;
- $numsections = isset($this->questionsbysec) ? count($this->questionsbysec) : 0;
- if ($this->has_dependencies()) {
- while (!$this->eligible_questions_on_page($secnum, $rid)) {
- $secnum++;
- // We have reached the end of questionnaire on a page without any question left.
- if ($secnum > $numsections) {
- $secnum = false;
- break;
- }
- }
- }
- return $secnum;
- }
-
- /**
- * Determine the previous valid page and return it. Return false if no valid previous page.
- * @param int $secnum
- * @param int $rid
- * @return int | bool
- */
- public function prev_page($secnum, $rid) {
- $secnum--;
- if ($this->has_dependencies()) {
- while (($secnum > 0) && !$this->eligible_questions_on_page($secnum, $rid)) {
- $secnum--;
- }
- }
- if ($secnum === 0) {
- $secnum = false;
- }
- return $secnum;
- }
-
- /**
- * Return the correct action to a next page request.
- * @param mod_questionnaire\responsetype\response\response $response
- * @param int $userid
- * @return bool|int|string
- */
- public function next_page_action($response, $userid) {
- $msg = $this->response_check_format($response->sec, $response);
- if (empty($msg)) {
- $response->rid = $this->existing_response_action($response, $userid);
- return $this->next_page($response->sec, $response->rid);
- } else {
- return $msg;
- }
- }
-
- /**
- * Return the correct action to a previous page request.
- * @param mod_questionnaire\responsetype\response\response $response
- * @param int $userid
- * @return bool|int
- */
- public function previous_page_action($response, $userid) {
- $response->rid = $this->existing_response_action($response, $userid);
- return $this->prev_page($response->sec, $response->rid);
- }
-
- /**
- * Handle updating an existing response.
- * @param mod_questionnaire\responsetype\response\response $response
- * @param int $userid
- * @return bool|int
- */
- public function existing_response_action($response, $userid) {
- $this->response_delete($response->rid, $response->sec);
- return $this->response_insert($response, $userid);
- }
-
- /**
- * Are there any eligible questions to be displayed on the specified page/section.
- * @param int $secnum The section number to check.
- * @param int $rid The current response id.
- * @return boolean
- */
- public function eligible_questions_on_page($secnum, $rid) {
- $questionstodisplay = false;
-
- foreach ($this->questionsbysec[$secnum] as $questionid) {
- if ($this->questions[$questionid]->dependency_fulfilled($rid, $this->questions)) {
- $questionstodisplay = true;
- break;
- }
- }
- return $questionstodisplay;
- }
-
- // Display Methods.
-
- /**
- * The main display method for the survey. Adds HTML to the templates.
- * @param int $quser
- * @param bool $userid
- * @return string|void
- */
- public function print_survey($quser, $userid = false) {
- global $SESSION, $CFG;
-
- if (!($formdata = data_submitted()) || !confirm_sesskey()) {
- $formdata = new stdClass();
- }
-
- $formdata->rid = $this->get_latest_responseid($quser);
- // If student saved a "resume" questionnaire OR left a questionnaire unfinished
- // and there are more pages than one find the page of the last answered question.
- if (($formdata->rid != 0) && (empty($formdata->sec) || intval($formdata->sec) < 1)) {
- $formdata->sec = $this->response_select_max_sec($formdata->rid);
- }
- if (empty($formdata->sec)) {
- $formdata->sec = 1;
- } else {
- $formdata->sec = (intval($formdata->sec) > 0) ? intval($formdata->sec) : 1;
- }
-
- $numsections = isset($this->questionsbysec) ? count($this->questionsbysec) : 0; // Indexed by section.
- $msg = '';
- $action = $CFG->wwwroot . '/mod/questionnaire/complete.php?id=' . $this->cm->id;
-
- // TODO - Need to rework this. Too much crossover with ->view method.
-
- // Skip logic :: if this is page 1, it cannot be the end page with no questions on it!
- if ($formdata->sec == 1) {
- $SESSION->questionnaire->end = false;
- }
-
- if (!empty($formdata->submit)) {
- // Skip logic: we have reached the last page without any questions on it.
- if (isset($SESSION->questionnaire->end) && $SESSION->questionnaire->end == true) {
- return;
- }
-
- $msg = $this->response_check_format($formdata->sec, $formdata);
- if (empty($msg)) {
- return;
- }
- $formdata->rid = $this->existing_response_action($formdata, $userid);
- }
-
- if (!empty($formdata->resume) && ($this->resume)) {
- $this->response_delete($formdata->rid, $formdata->sec);
- $formdata->rid = $this->response_insert($formdata, $quser, true);
- $this->response_goto_saved($action);
- return;
- }
-
- // Save each section 's $formdata somewhere in case user returns to that page when navigating the questionnaire.
- if (!empty($formdata->next)) {
- $msg = $this->response_check_format($formdata->sec, $formdata);
- if ($msg) {
- $formdata->next = '';
- $formdata->rid = $this->existing_response_action($formdata, $userid);
- } else {
- $nextsec = $this->next_page_action($formdata, $userid);
- if ($nextsec === false) {
- $SESSION->questionnaire->end = true; // End of questionnaire reached on a no questions page.
- $formdata->sec = $numsections + 1;
- } else {
- $formdata->sec = $nextsec;
- }
- }
- }
-
- if (!empty($formdata->prev)) {
- // If skip logic and this is last page reached with no questions,
- // unlock questionnaire->end to allow navigate back to previous page.
- if (isset($SESSION->questionnaire->end) && ($SESSION->questionnaire->end == true)) {
- $SESSION->questionnaire->end = false;
- $formdata->sec--;
- }
-
- // Prevent navigation to previous page if wrong format in answered questions).
- $msg = $this->response_check_format($formdata->sec, $formdata, false, true);
- if ($msg) {
- $formdata->prev = '';
- $formdata->rid = $this->existing_response_action($formdata, $userid);
- } else {
- $prevsec = $this->previous_page_action($formdata, $userid);
- if ($prevsec === false) {
- $formdata->sec = 0;
- } else {
- $formdata->sec = $prevsec;
- }
- }
- }
-
- if (!empty($formdata->rid)) {
- $this->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' => $this->id, 'sid' => $this->survey->id, 'rid' => $formdatarid, 'sec' => $formdata->sec, 'sesskey' => sesskey()]));
- if (isset($this->questions) && $numsections) { // Sanity check.
- $this->survey_render($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 ($this->resume) {
- $controlbuttons['resume'] = ['type' => 'submit', 'class' => 'btn btn-secondary control-button-save',
- 'value' => get_string('save_and_exit', 'questionnaire')];
- }
-
- // Add a 'hidden' variable for the mod's 'view.php', and use a language variable for the submit button.
-
- 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;
- }
-
- /**
- * Print the entire survey page.
- * @param stdClass $formdata
- * @param int $section
- * @param string $message
- * @return bool|void
- */
- private function survey_render(&$formdata, $section = 1, $message = '') {
-
- $this->usehtmleditor = null;
-
- if (empty($section)) {
- $section = 1;
- }
- $numsections = isset($this->questionsbysec) ? count($this->questionsbysec) : 0;
- 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); // Invalid section.
- }
-
- // Check to see if there are required questions.
- $hasrequired = $this->has_required($section);
-
- // Find out what question number we are on $i New fix for question numbering.
- $i = 0;
- if ($section > 1) {
- for ($j = 2; $j <= $section; $j++) {
- foreach ($this->questionsbysec[$j - 1] as $questionid) {
- if ($this->questions[$questionid]->type_id < QUESPAGEBREAK) {
- $i++;
- }
- }
- }
- }
-
- $this->print_survey_start($message, $section, $numsections, $hasrequired, '', 1);
- // Only show progress bar on questionnaires with more than one page.
- if ($this->progressbar && isset($this->questionsbysec) && count($this->questionsbysec) > 1) {
- $this->page->add_to_page(
- 'progressbar',
- $this->renderer->render_progress_bar($section, $this->questionsbysec)
- );
- }
- if (key_exists($section, $this->questionsbysec)) {
- foreach ($this->questionsbysec[$section] as $questionid) {
- if ($this->questions[$questionid]->is_numbered()) {
- $i++;
- }
- // Need questionnaire id to get the questionnaire object in sectiontext (Label) question class.
- $formdata->questionnaire_id = $this->id;
- if (isset($formdata->rid) && !empty($formdata->rid)) {
- $this->add_response($formdata->rid);
- } else {
- $this->add_response_from_formdata($formdata);
- }
- $this->page->add_to_page(
- 'questions',
- $this->renderer->question_output(
- $this->questions[$questionid],
- (isset($this->responses[$formdata->rid]) ? $this->responses[$formdata->rid] : []),
- $i,
- $this->usehtmleditor,
- []
- )
- );
- }
- }
-
- $this->print_survey_end($section, $numsections);
-
- return;
- }
-
- /**
- * Print the start of the survey page.
- * @param string $message
- * @param int $section
- * @param int $numsections
- * @param bool $hasrequired
- * @param string $rid
- * @param bool $blankquestionnaire
- * @param string $outputtarget
- */
- private function print_survey_start(
- $message,
- $section,
- $numsections,
- $hasrequired,
- $rid = '',
- $blankquestionnaire = false,
- $outputtarget = 'html'
- ) {
- global $CFG, $DB;
- require_once($CFG->libdir . '/filelib.php');
-
- $userid = '';
- $resp = '';
- $groupname = '';
- $currentgroupid = 0;
- $timesubmitted = '';
- // Available group modes (0 = no groups; 1 = separate groups; 2 = visible groups).
- if ($rid) {
- $courseid = $this->course->id;
- if ($resp = $DB->get_record('questionnaire_response', ['id' => $rid])) {
- if ($this->respondenttype == 'fullname') {
- $userid = $resp->userid;
- // Display name of group(s) that student belongs to... if questionnaire is set to Groups separate or visible.
- if (groups_get_activity_groupmode($this->cm, $this->course)) {
- if ($groups = groups_get_all_groups($courseid, $resp->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' => $this->survey->id,
- 'context' => $this->context,
- 'courseid' => $this->course->id,
- '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 = $DB->get_record('user', ['id' => $userid])) {
- $ruser = fullname($user);
- }
- }
- if ($this->respondenttype == 'anonymous') {
- $ruser = '- ' . get_string('anonymous', 'questionnaire') . ' -';
- } else {
- // JR DEV comment following line out if you do NOT want time submitted displayed in Anonymous surveys.
- if ($resp->submitted) {
- $timesubmitted = ' ' . get_string('submitted', 'questionnaire') . ' ' . userdate($resp->submitted);
- }
- }
- }
- if ($ruser) {
- $respinfo = '';
- if ($outputtarget == 'html') {
- // Disable the pdf function for now, until it looks a lot better.
- if (false) {
- $linkname = get_string('downloadpdf', 'mod_questionnaire');
- $link = new moodle_url(
- '/mod/questionnaire/report.php',
- [
- 'action' => 'vresp',
- 'instance' => $this->id,
- 'target' => 'pdf',
- 'individualresponse' => 1,
- 'rid' => $rid,
- ]
- );
- $downpdficon = new pix_icon('b/pdfdown', $linkname, 'mod_questionnaire');
- $respinfo .= $this->renderer->action_link($link, null, null, null, $downpdficon);
- }
-
- $linkname = get_string('print', 'mod_questionnaire');
- $link = new \moodle_url(
- '/mod/questionnaire/report.php',
- [
- 'action' => 'vresp',
- 'instance' => $this->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 ($this->survey_is_public()) {
- // For a public questionnaire, look for the course that used it.
- $coursename = '';
- $sql = 'SELECT q.id, q.course, c.fullname ' .
- 'FROM {questionnaire_response} qr ' .
- 'INNER JOIN {questionnaire} q ON qr.questionnaireid = q.id ' .
- 'INNER JOIN {course} c ON q.course = c.id ' .
- 'WHERE qr.id = ? AND qr.complete = ? ';
- if ($record = $DB->get_record_sql($sql, [$rid, 'y'])) {
- $coursename = $record->fullname;
- }
- $respinfo .= ' ' . get_string('course') . ': ' . $coursename;
- }
- $respinfo .= $groupname;
- $respinfo .= $timesubmitted;
- $this->page->add_to_page('respondentinfo', $this->renderer->respondent_info($respinfo));
- }
-
- // We don't want to display the print icon in the print popup window itself!
- if ($this->capabilities->printblank && $blankquestionnaire && $section == 1) {
- // Open print friendly as popup window.
- $linkname = ' ' . get_string('printblank', 'questionnaire');
- $title = get_string('printblanktooltip', 'questionnaire');
- $url = '/mod/questionnaire/print.php?qid=' . $this->id . '&rid=0&' . 'courseid=' .
- $this->course->id . '&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) {
- if (!empty($this->survey->title)) {
- $this->survey->title = format_string($this->survey->title);
- $this->page->add_to_page('title', $this->survey->title);
- }
- if (!empty($this->survey->subtitle)) {
- $this->survey->subtitle = format_string($this->survey->subtitle);
- $this->page->add_to_page('subtitle', $this->survey->subtitle);
- }
- if ($this->survey->info) {
- $infotext = file_rewrite_pluginfile_urls(
- $this->survey->info,
- 'pluginfile.php',
- $this->context->id,
- 'mod_questionnaire',
- 'info',
- $this->survey->id
- );
- $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));
- }
- }
-
- /**
- * Print the end of the survey page.
- * @param int $section
- * @param int $numsections
- */
- private function print_survey_end($section, $numsections) {
- // If no pages autonumbering.
- if (!$this->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'
- )
- );
- }
- }
-
- /**
- * Display a survey suitable for printing.
- * @param int $courseid
- * @param string $message
- * @param string $referer
- * @param int $rid
- * @param bool $blankquestionnaire If we are printing a blank questionnaire.
- * @return false|void
- */
- public function survey_print_render($courseid, $message = '', $referer = '', $rid = 0, $blankquestionnaire = false) {
- global $DB, $CFG;
-
- if (! $course = $DB->get_record("course", ["id" => $courseid])) {
- throw new \moodle_exception('incorrectcourseid', 'mod_questionnaire');
- }
-
- $this->course = $course;
-
- if (!empty($rid)) {
- // If we're viewing a response, use this method.
- $this->view_response($rid, $referer);
- return;
- }
-
- if (empty($section)) {
- $section = 1;
- }
-
- if (isset($this->questionsbysec)) {
- $numsections = count($this->questionsbysec);
- } else {
- $numsections = 0;
- }
-
- if ($section > $numsections) {
- return(false); // Invalid section.
- }
-
- $hasrequired = $this->has_required();
-
- // Find out what question number we are on $i.
- $i = 1;
- for ($j = 2; $j <= $section; $j++) {
- $i += count($this->questionsbysec[$j - 1]);
- }
-
- $action = $CFG->wwwroot . '/mod/questionnaire/preview.php?id=' . $this->cm->id;
- $this->page->add_to_page(
- 'formstart',
- $this->renderer->complete_formstart($action)
- );
- // Print all sections.
- $formdata = new stdClass();
- $errors = 1;
- if (data_submitted()) {
- $formdata = data_submitted();
- $formdata->rid = $formdata->rid ?? 0;
- $this->add_response_from_formdata($formdata);
- $pageerror = '';
- $s = 1;
- $errors = 0;
- foreach ($this->questionsbysec as $section) {
- $errormessage = $this->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->print_survey_start($message, 1, 1, $hasrequired, '');
-
- if (($referer == 'preview') && $this->has_dependencies()) {
- $allqdependants = $this->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 ($this->questionsbysec as $section) {
- $output = '';
- if ($numsections > 1) {
- $output .= $this->renderer->print_preview_pagenumber(get_string('page', 'questionnaire') . ' ' . $page);
- $page++;
- }
- foreach ($section as $questionid) {
- if (!$this->questions[$questionid]->is_numbered()) {
- $i--;
- }
- if (isset($allqdependants[$questionid])) {
- $dependants = $allqdependants[$questionid];
- } else {
- $dependants = [];
- }
- $this->questions[$questionid]->set_isprint($referer === 'print');
- $output .= $this->renderer->question_output(
- $this->questions[$questionid],
- $this->responses[0] ?? new \mod_questionnaire\responsetype\response\response(),
- $i++,
- null,
- $dependants
- );
- $this->page->add_to_page('questions', $output);
- $output = '';
- }
- }
- // End of questions.
- if ($referer == 'preview' && !$blankquestionnaire) {
- $url = $CFG->wwwroot . '/mod/questionnaire/preview.php?id=' . $this->cm->id;
- $this->page->add_to_page(
- 'formend',
- $this->renderer->print_preview_formend(
- $url,
- get_string('submitpreview', 'questionnaire'),
- get_string('reset')
- )
- );
- }
- return;
- }
-
- /**
- * Update an existing survey.
- * @param stdClass $sdata
- * @return bool|int
- */
- public function survey_update($sdata) {
- global $DB;
-
- $errstr = ''; // TODO: notused!
-
- // New survey.
- if (empty($this->survey->id)) {
- // Create a new survey in the database.
- $fields = [
- 'name',
- 'realm',
- 'title',
- 'subtitle',
- 'email',
- 'theme',
- 'thanks_page',
- 'thank_head',
- 'thank_body',
- 'feedbacknotes',
- 'info',
- 'feedbacksections',
- 'feedbackscores',
- 'chart_type',
- ];
- // Theme field deprecated.
- $record = new stdClass();
- $record->id = 0;
- $record->courseid = $sdata->courseid;
- foreach ($fields as $f) {
- if (isset($sdata->$f)) {
- $record->$f = $sdata->$f;
- }
- }
-
- $this->survey = new stdClass();
- $this->survey->id = $DB->insert_record('questionnaire_survey', $record);
- $this->add_survey($this->survey->id);
-
- if (!$this->survey->id) {
- $errstr = get_string('errnewname', 'questionnaire') . ' [ : ]'; // TODO: notused!
- return(false);
- }
- } else {
- if (empty($sdata->name) || empty($sdata->title) || empty($sdata->realm)) {
- return(false);
- }
- if (!isset($sdata->chart_type)) {
- $sdata->chart_type = '';
- }
-
- $fields = [
- 'name',
- 'realm',
- 'title',
- 'subtitle',
- 'email',
- 'theme',
- 'thanks_page',
- 'thank_head',
- 'thank_body',
- 'feedbacknotes',
- 'info',
- 'feedbacksections',
- 'feedbackscores',
- 'chart_type',
- ];
- $name = $DB->get_field('questionnaire_survey', 'name', ['id' => $this->survey->id]);
-
- // Trying to change survey name.
- if (trim($name) != trim(stripslashes($sdata->name))) { // Var $sdata will already have slashes added to it.
- $count = $DB->count_records('questionnaire_survey', ['name' => $sdata->name]);
- if ($count != 0) {
- $errstr = get_string('errnewname', 'questionnaire'); // TODO: notused!
- return(false);
- }
- }
-
- // UPDATE the row in the DB with current values.
- $surveyrecord = new stdClass();
- $surveyrecord->id = $this->survey->id;
- foreach ($fields as $f) {
- if (isset($sdata->{$f})) {
- $surveyrecord->$f = trim($sdata->{$f});
- }
- }
-
- $result = $DB->update_record('questionnaire_survey', $surveyrecord);
- if (!$result) {
- $errstr = get_string('warning', 'questionnaire') . ' [ : ]'; // TODO: notused!
- return(false);
- }
- }
-
- return($this->survey->id);
- }
-
- /**
- * Creates an editable copy of a survey.
- * @param int $owner
- * @return bool|int
- */
- public function survey_copy($owner) {
- global $DB;
-
- // Clear the sid, clear the creation date, change the name, and clear the status.
- $survey = clone($this->survey);
-
- unset($survey->id);
- $survey->courseid = $owner;
- // Make sure that the survey name is not larger than the field size (CONTRIB-2999). Leave room for extra chars.
- $survey->name = core_text::substr($survey->name, 0, (64 - 10));
-
- $survey->name .= '_copy';
- $survey->status = 0;
-
- // Check for 'name' conflict, and resolve.
- $i = 0;
- $name = $survey->name;
- while ($DB->count_records('questionnaire_survey', ['name' => $name]) > 0) {
- $name = $survey->name . (++$i);
- }
- if ($i) {
- $survey->name .= $i;
- }
-
- // Create new survey.
- if (!($newsid = $DB->insert_record('questionnaire_survey', $survey))) {
- return(false);
- }
-
- // Make copies of all the questions.
- $pos = 1;
- // Skip logic: some changes needed here for dependencies down below.
- $qidarray = [];
- $cidarray = [];
- foreach ($this->questions as $question) {
- // Fix some fields first.
- $oldid = $question->id;
- unset($question->id);
- $question->surveyid = $newsid;
- $question->position = $pos++;
-
- // Copy question to new survey.
- if (!($newqid = $DB->insert_record('questionnaire_question', $question))) {
- return(false);
- }
- $qidarray[$oldid] = $newqid;
- foreach ($question->choices as $key => $choice) {
- $oldcid = $key;
- $newchoice = (object) [
- 'question_id' => $newqid,
- 'content' => $choice->content,
- 'value' => $choice->value,
- ];
- if (!$newcid = $DB->insert_record('questionnaire_quest_choice', $newchoice)) {
- return(false);
- }
- $cidarray[$oldcid] = $newcid;
- }
- }
-
- // Replicate all dependency data.
- if ($dependquestions = $DB->get_records('questionnaire_dependency', ['surveyid' => $this->survey->id], 'questionid')) {
- foreach ($dependquestions as $dquestion) {
- $record = new stdClass();
- $record->questionid = $qidarray[$dquestion->questionid];
- $record->surveyid = $newsid;
- $record->dependquestionid = $qidarray[$dquestion->dependquestionid];
- // The response may not use choice id's (example boolean). If not, just copy the value.
- $responsetype = $this->questions[$dquestion->dependquestionid]->responsetype;
- if ($responsetype->transform_choiceid($dquestion->dependchoiceid) == $dquestion->dependchoiceid) {
- $record->dependchoiceid = $cidarray[$dquestion->dependchoiceid];
- } else {
- $record->dependchoiceid = $dquestion->dependchoiceid;
- }
- $record->dependlogic = $dquestion->dependlogic;
- $record->dependandor = $dquestion->dependandor;
- $DB->insert_record('questionnaire_dependency', $record);
- }
- }
-
- // Replicate any feedback data.
- // TODO: Need to handle image attachments (same for other copies above).
- if ($fbsections = $DB->get_records('questionnaire_fb_sections', ['surveyid' => $this->survey->id], 'id')) {
- foreach ($fbsections as $fbsid => $fbsection) {
- $fbsection->surveyid = $newsid;
- $scorecalculation = section::decode_scorecalculation($fbsection->scorecalculation);
- $newscorecalculation = [];
- foreach ($scorecalculation as $qid => $val) {
- $newscorecalculation[$qidarray[$qid]] = $val;
- }
- $fbsection->scorecalculation = serialize($newscorecalculation);
- unset($fbsection->id);
- $newfbsid = $DB->insert_record('questionnaire_fb_sections', $fbsection);
- if ($feedbackrecs = $DB->get_records('questionnaire_feedback', ['sectionid' => $fbsid], 'id')) {
- foreach ($feedbackrecs as $feedbackrec) {
- $feedbackrec->sectionid = $newfbsid;
- unset($feedbackrec->id);
- $DB->insert_record('questionnaire_feedback', $feedbackrec);
- }
- }
- }
- }
-
- return($newsid);
- }
-
- // RESPONSE LIBRARY.
-
- /**
- * Check that all questions have been answered in a suitable way.
- * @param int $section
- * @param stdClass $formdata
- * @param bool $checkmissing
- * @param bool $checkwrongformat
- * @return string
- */
- private function response_check_format($section, $formdata, $checkmissing = true, $checkwrongformat = true) {
- $missing = 0;
- $strmissing = ''; // Missing questions.
- $wrongformat = 0;
- $strwrongformat = ''; // Wrongly formatted questions (Numeric, 5:Check Boxes, Date).
- $i = 1;
- for ($j = 2; $j <= $section; $j++) {
- // ADDED A SIMPLE LOOP FOR MAKING SURE PAGE BREAKS (type 99) AND LABELS (type 100) ARE NOT ALLOWED.
- foreach ($this->questionsbysec[$j - 1] as $questionid) {
- $tid = $this->questions[$questionid]->type_id;
- if ($tid < QUESPAGEBREAK) {
- $i++;
- }
- }
- }
- $qnum = $i - 1;
-
- if (key_exists($section, $this->questionsbysec)) {
- foreach ($this->questionsbysec[$section] as $questionid) {
- if ($this->questions[$questionid]->is_numbered()) {
- $qnum++;
- }
- if (!$this->questions[$questionid]->response_complete($formdata)) {
- $missing++;
- $strnum = get_string('num', 'questionnaire') . $qnum . '. ';
- $strmissing .= $strnum;
- // Pop-up notification at the point of the error.
- $strnoti = get_string('missingquestion', 'questionnaire') . $strnum;
- $this->questions[$questionid]->add_notification($strnoti);
- }
- if (!$this->questions[$questionid]->response_valid($formdata)) {
- $wrongformat++;
- $strwrongformat .= get_string('num', 'questionnaire') . $qnum . '. ';
- }
- }
- }
- $message = '';
- $nonumbering = false;
- // If no questions autonumbering do not display missing question(s) number(s).
- if (!$this->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);
- }
-
- /**
- * Delete the spcified response.
- * @param int $rid
- * @param null|int $sec
- */
- private function response_delete($rid, $sec = null) {
- global $DB;
-
- if (empty($rid)) {
- return;
- }
-
- if ($sec != null) {
- if ($sec < 1) {
- return;
- }
-
- // Skip logic.
- $numsections = isset($this->questionsbysec) ? count($this->questionsbysec) : 0;
- $sec = min($numsections, $sec);
-
- /* get question_id's in this section */
- $qids = [];
- foreach ($this->questionsbysec[$sec] as $questionid) {
- $qids[] = $questionid;
- }
- if (empty($qids)) {
- return;
- } else {
- [$qsql, $params] = $DB->get_in_or_equal($qids);
- $qsql = ' AND question_id ' . $qsql;
- }
- } else {
- /* delete all */
- $qsql = '';
- $params = [];
- }
-
- /* delete values */
- $select = 'response_id = \'' . $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);
- }
- }
-
- /**
- * Commit the specified response.
- * @param int $rid
- * @return bool
- */
- private function response_commit($rid) {
- global $DB;
-
- $record = new stdClass();
- $record->id = $rid;
- $record->complete = 'y';
- $record->submitted = time();
-
- if ($this->grade < 0) {
- $record->grade = 1; // Don't know what to do if its a scale...
- } else {
- $record->grade = $this->grade;
- }
- return $DB->update_record('questionnaire_response', $record);
- }
-
- /**
- * Get the latest response id for the user, or verify that the given response id is valid.
- * @param int $userid
- * @return int
- */
- public function get_latest_responseid($userid) {
- global $DB;
-
- // Find latest in progress rid.
- $params = ['questionnaireid' => $this->id, 'userid' => $userid, 'complete' => 'n'];
- if ($records = $DB->get_records('questionnaire_response', $params, 'submitted DESC', 'id,questionnaireid', 0, 1)) {
- $rec = reset($records);
- return $rec->id;
- } else {
- return 0;
- }
- }
-
- /**
- * Returns the number of the section in which questions have been answered in a response.
- * @param int $rid
- * @return int
- */
- private function response_select_max_sec($rid) {
- global $DB;
-
- $pos = $this->response_select_max_pos($rid);
- $select = 'surveyid = ? AND type_id = ? AND position < ? AND deleted IS NULL';
- $params = [$this->sid, QUESPAGEBREAK, $pos];
- $max = $DB->count_records_select('questionnaire_question', $select, $params) + 1;
-
- return $max;
- }
-
- /**
- * Returns the position of the last answered question in a response.
- * @param int $rid
- * @return int
- */
- private function response_select_max_pos($rid) {
- global $DB;
-
- $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.response_id = ? AND ' .
- 'q.id = a.question_id AND ' .
- 'q.surveyid = ? AND ' .
- 'q.deleted IS NULL';
- if ($record = $DB->get_record_sql($sql, [$rid, $this->sid])) {
- $newmax = (int)$record->num;
- if ($newmax > $max) {
- $max = $newmax;
- }
- }
- }
- return $max;
- }
-
- /**
- * Handle all submission notification actions.
- * @param int $rid The id of the response record.
- * @return boolean Operation success.
- *
- */
- private function submission_notify($rid) {
- global $DB;
-
- $success = true;
-
- if (isset($this->survey)) {
- if (isset($this->survey->email)) {
- $email = $this->survey->email;
- } else {
- $email = $DB->get_field('questionnaire_survey', 'email', ['id' => $this->survey->id]);
- }
- } else {
- $email = '';
- }
-
- if (!empty($email)) {
- $success = $this->response_send_email($rid, $email);
- }
-
- if (!empty($this->notifications)) {
- // Handle notification of submissions.
- $success = $this->send_submission_notifications($rid) && $success;
- }
-
- return $success;
- }
-
- /**
- * Send submission notifications to users with "submissionnotification" capability.
- * @param int $rid The id of the response record.
- * @return boolean Operation success.
- *
- */
- private function send_submission_notifications($rid) {
- global $CFG, $USER;
-
- $this->add_response($rid);
- $message = '';
-
- if ($this->notifications == 2) {
- $message .= $this->get_full_submission_for_notifications($rid);
- }
-
- $success = true;
- if ($notifyusers = $this->get_notifiable_users($USER->id)) {
- $info = new stdClass();
- // Need to handle user differently for anonymous surveys.
- if ($this->respondenttype != 'anonymous') {
- $info->userfrom = $USER;
- $info->username = fullname($info->userfrom, true);
- $info->profileurl = $CFG->wwwroot . '/user/view.php?id=' . $info->userfrom->id . '&course=' . $this->course->id;
- $langstringtext = 'submissionnotificationtextuser';
- $langstringhtml = 'submissionnotificationhtmluser';
- } else {
- $info->userfrom = \core_user::get_noreply_user();
- $info->username = '';
- $info->profileurl = '';
- $langstringtext = 'submissionnotificationtextanon';
- $langstringhtml = 'submissionnotificationhtmlanon';
- }
- $info->name = format_string($this->name);
- $info->submissionurl = $CFG->wwwroot . '/mod/questionnaire/report.php?action=vresp&sid=' . $this->survey->id .
- '&rid=' . $rid . '&instance=' . $this->id;
- $info->coursename = format_string($this->course->fullname);
-
- $info->postsubject = get_string('submissionnotificationsubject', 'questionnaire');
- $info->posttext = get_string($langstringtext, 'questionnaire', $info);
- $info->posthtml = '' . get_string($langstringhtml, 'questionnaire', $info) . '
';
- if (!empty($message)) {
- $info->posttext .= html_to_text($message);
- $info->posthtml .= $message;
- }
-
- foreach ($notifyusers as $notifyuser) {
- $info->userto = $notifyuser;
- $this->send_message($info, 'notification');
- }
- }
-
- return $success;
- }
-
- /**
- * Message someone about something.
- *
- * @param object $info The information for the message.
- * @param string $eventtype
- * @return void
- */
- private function send_message($info, $eventtype) {
- $eventdata = new \core\message\message();
- $eventdata->courseid = $this->course->id;
- $eventdata->modulename = 'questionnaire';
- $eventdata->userfrom = $info->userfrom;
- $eventdata->userto = $info->userto;
- $eventdata->subject = $info->postsubject;
- $eventdata->fullmessage = $info->posttext;
- $eventdata->fullmessageformat = FORMAT_PLAIN;
- $eventdata->fullmessagehtml = $info->posthtml;
- $eventdata->smallmessage = $info->postsubject;
-
- $eventdata->name = $eventtype;
- $eventdata->component = 'mod_questionnaire';
- $eventdata->notification = 1;
- $eventdata->contexturl = $info->submissionurl;
- $eventdata->contexturlname = $info->name;
-
- message_send($eventdata);
- }
-
- /**
- * Returns a list of users that should receive notification about given submission.
- *
- * @param int $userid The submission to grade
- * @return array
- */
- public function get_notifiable_users($userid) {
- // Potential users should be active users only.
- $potentialusers = get_enrolled_users(
- $this->context,
- 'mod/questionnaire:submissionnotification',
- null,
- 'u.*',
- null,
- null,
- null,
- true
- );
-
- $notifiableusers = [];
- if (groups_get_activity_groupmode($this->cm) == SEPARATEGROUPS) {
- if ($groups = groups_get_all_groups($this->course->id, $userid, $this->cm->groupingid)) {
- foreach ($groups as $group) {
- foreach ($potentialusers as $potentialuser) {
- if ($potentialuser->id == $userid) {
- // Do not send self.
- continue;
- }
- if (groups_is_member($group->id, $potentialuser->id)) {
- $notifiableusers[$potentialuser->id] = $potentialuser;
- }
- }
- }
- } else {
- // User not in group, try to find graders without group.
- foreach ($potentialusers as $potentialuser) {
- if ($potentialuser->id == $userid) {
- // Do not send self.
- continue;
- }
- if (!groups_has_membership($this->cm, $potentialuser->id)) {
- $notifiableusers[$potentialuser->id] = $potentialuser;
- }
- }
- }
- } else {
- foreach ($potentialusers as $potentialuser) {
- if ($potentialuser->id == $userid) {
- // Do not send self.
- continue;
- }
- $notifiableusers[$potentialuser->id] = $potentialuser;
- }
- }
- return $notifiableusers;
- }
-
- /**
- * Return a formatted string containing all the questions and answers for a specific submission.
- * @param int $rid
- * @return string
- */
- private function get_full_submission_for_notifications($rid) {
- $responses = $this->get_full_submission_for_export($rid);
- $message = '';
- foreach ($responses as $response) {
- $message .= html_to_text(format_string($response->questionname)) . " \n";
- $message .= get_string('question') . ': ' . html_to_text(format_string($response->questiontext)) . " \n";
- $message .= get_string('answers', 'questionnaire') . ": \n";
- foreach ($response->answers as $answer) {
- $message .= html_to_text($answer) . " \n";
- }
- $message .= " \n";
- }
-
- return $message;
- }
-
- /**
- * Construct the response data for a given response and return a structured export.
- * @param int $rid
- * @return string
- * @throws coding_exception
- */
- public function get_structured_response($rid) {
- $this->add_response($rid);
- return $this->get_full_submission_for_export($rid);
- }
-
- /**
- * Return a JSON structure containing all the questions and answers for a specific submission.
- * @param int $rid
- * @return array
- */
- private function get_full_submission_for_export($rid) {
- if (!isset($this->responses[$rid])) {
- $this->add_response($rid);
- }
-
- $exportstructure = [];
- foreach ($this->questions as $question) {
- $rqid = 'q' . $question->id;
- $response = new stdClass();
- $response->questionname = $question->position . '. ' . $question->name;
- $response->questiontext = $question->content;
- $response->answers = [];
- if ($question->type_id == 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;
- }
-
- /**
- * Format the submission answers for legacy email delivery.
- * @param array $answers The array of response answers.
- * @return array The formatted set of answers as plain text and HTML.
- */
- private function get_formatted_answers_for_emails($answers) {
- global $USER;
-
- // Line endings for html and plaintext emails.
- $endhtml = "\r\n ";
- $endplaintext = "\r\n";
-
- reset($answers);
-
- $formatted = ['plaintext' => '', 'html' => ''];
- for ($i = 0; $i < count($answers[0]); $i++) {
- $sep = ' : ';
-
- switch ($i) {
- case 1:
- $sep = ' ';
- break;
- case 4:
- $formatted['plaintext'] .= get_string('user') . ' ';
- $formatted['html'] .= get_string('user') . ' ';
- break;
- case 6:
- if ($this->respondenttype != 'anonymous') {
- $formatted['html'] .= get_string('email') . $sep . $USER->email . $endhtml;
- $formatted['plaintext'] .= get_string('email') . $sep . $USER->email . $endplaintext;
- }
- }
- $formatted['html'] .= $answers[0][$i] . $sep . $answers[1][$i] . $endhtml;
- $formatted['plaintext'] .= $answers[0][$i] . $sep . $answers[1][$i] . $endplaintext;
- }
-
- return $formatted;
- }
-
- /**
- * Send the full response submission to the defined email addresses.
- * @param int $rid The id of the response record.
- * @param string $email The comma separated list of emails to send to.
- * @return bool
- */
- private function response_send_email($rid, $email) {
- global $CFG;
-
- $submission = $this->generate_csv(0, $rid, '', null, 1);
- if (!empty($submission)) {
- $answers = $this->get_formatted_answers_for_emails($submission);
- } else {
- $answers = ['html' => '', 'plaintext' => ''];
- }
-
- $name = s($this->name);
- if (empty($email)) {
- return(false);
- }
-
- // Line endings for html and plaintext emails.
- $endhtml = "\r\n ";
- $endplaintext = "\r\n";
-
- $subject = get_string('surveyresponse', 'questionnaire') . ": $name [$rid]";
- $url = $CFG->wwwroot . '/mod/questionnaire/report.php?action=vresp&sid=' . $this->survey->id .
- '&rid=' . $rid . '&instance=' . $this->id;
-
- // Html and plaintext body.
- $bodyhtml = '' . $url . ' ' . $endhtml;
- $bodyplaintext = $url . $endplaintext;
- $bodyhtml .= get_string('surveyresponse', 'questionnaire') . ' "' . $name . '"' . $endhtml;
- $bodyplaintext .= get_string('surveyresponse', 'questionnaire') . ' "' . $name . '"' . $endplaintext;
-
- $bodyhtml .= $answers['html'];
- $bodyplaintext .= $answers['plaintext'];
-
- // Use plaintext version for altbody.
- $altbody = "\n$bodyplaintext\n";
-
- $return = true;
- $mailaddresses = preg_split('/,|;/', $email);
- foreach ($mailaddresses as $email) {
- $userto = new stdClass();
- $userto->email = trim($email);
- $userto->mailformat = 1;
- // Dummy userid to keep email_to_user happy in moodle 2.6.
- $userto->id = -10;
- $userfrom = $CFG->noreplyaddress;
- if (email_to_user($userto, $userfrom, $subject, $altbody, $bodyhtml)) {
- $return = $return && true;
- } else {
- $return = false;
- }
- }
- return $return;
- }
-
- /**
- * Insert the provided response.
- * @param object $responsedata An object containing all data for the response.
- * @param int $userid
- * @param bool $resume
- * @return bool|int
- */
- public function response_insert($responsedata, $userid, $resume = false) {
- global $DB;
-
- $record = new stdClass();
- $record->submitted = time();
-
- if (empty($responsedata->rid)) {
- // Create a uniqe id for this response.
- $record->questionnaireid = $this->id;
- $record->userid = $userid;
- $responsedata->rid = $DB->insert_record('questionnaire_response', $record);
- $responsedata->id = $responsedata->rid;
- } else {
- $record->id = $responsedata->rid;
- $DB->update_record('questionnaire_response', $record);
- }
- if ($resume) {
- // Log this saved response.
- // Needed for the event logging.
- $context = context_module::instance($this->cm->id);
- $anonymous = $this->respondenttype == 'anonymous';
- $params = [
- 'context' => $context,
- 'courseid' => $this->course->id,
- 'relateduserid' => $userid,
- 'anonymous' => $anonymous,
- 'other' => ['questionnaireid' => $this->id],
- ];
- $event = \mod_questionnaire\event\attempt_saved::create($params);
- $event->trigger();
- }
-
- if (!isset($responsedata->sec)) {
- $responsedata->sec = 1;
- }
- if (!empty($this->questionsbysec[$responsedata->sec])) {
- foreach ($this->questionsbysec[$responsedata->sec] as $questionid) {
- $this->questions[$questionid]->insert_response($responsedata);
- }
- }
- return($responsedata->rid);
- }
-
- /**
- * Get the answers for the all response types.
- * @param int $rid
- * @return array
- */
- private function response_select($rid) {
- // Response_bool (yes/no).
- $values = \mod_questionnaire\responsetype\boolean::response_select($rid);
-
- // Response_single (radio button or dropdown).
- $values += \mod_questionnaire\responsetype\single::response_select($rid);
-
- // Response_multiple.
- $values += \mod_questionnaire\responsetype\multiple::response_select($rid);
-
- // Response_rank.
- $values += \mod_questionnaire\responsetype\rank::response_select($rid);
-
- // Response_text.
- $values += \mod_questionnaire\responsetype\text::response_select($rid);
-
- // Response_date.
- $values += \mod_questionnaire\responsetype\date::response_select($rid);
-
- return($values);
- }
-
- /**
- * Redirect to the appropriate finish page.
- */
- private function response_goto_thankyou() {
- global $CFG, $USER, $DB;
-
- $select = 'id = ' . $this->survey->id;
- $fields = 'thanks_page, thank_head, thank_body';
- if ($result = $DB->get_record_select('questionnaire_survey', $select, null, $fields)) {
- $thankurl = $result->thanks_page;
- $thankhead = $result->thank_head;
- $thankbody = $result->thank_body;
- } else {
- $thankurl = '';
- $thankhead = '';
- $thankbody = '';
- }
- if (!empty($thankurl)) {
- if (!headers_sent()) {
- header("Location: $thankurl");
- exit;
- }
- echo '
-
-
- Thank You for completing this survey.
- Please click
- here to continue.
-
- ';
- exit;
- }
- if (empty($thankhead)) {
- $thankhead = get_string('thank_head', 'questionnaire');
- }
- if ($this->progressbar && isset($this->questionsbysec) && count($this->questionsbysec) > 1) {
- // Show 100% full progress bar on completion.
- $this->page->add_to_page(
- 'progressbar',
- $this->renderer->render_progress_bar(count($this->questionsbysec) + 1, $this->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',
- $this->context->id,
- 'mod_questionnaire',
- 'thankbody',
- $this->survey->id
- ),
- FORMAT_HTML,
- ['noclean' => true]
- )
- );
- // Default set currentgroup to view all participants.
- // TODO why not set to current respondent's groupid (if any)?
- $currentgroupid = 0;
- $currentgroupid = groups_get_activity_group($this->cm);
- if (!groups_is_member($currentgroupid, $USER->id)) {
- $currentgroupid = 0;
- }
- if ($this->capabilities->readownresponses) {
- $url = new moodle_url('myreport.php', ['id' => $this->cm->id, 'instance' => $this->cm->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' => $this->course->id]);
- $this->page->add_to_page('continue', $this->renderer->single_button($url, get_string('continue')));
- }
- return;
- }
-
- /**
- * Redirect to the provided url.
- * @param string $url
- */
- private function response_goto_saved($url) {
- 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=' . $this->course->id,
- get_string("backto", "moodle", $this->course->fullname)
- )
- );
-
- if ($this->resume) {
- $message = $this->user_access_messages($USER->id, true);
- if ($message === false) {
- if ($this->user_can_take($USER->id)) {
- if ($this->questions) { // Sanity check.
- if ($this->user_has_saved_response($USER->id)) {
- $this->page->add_to_page(
- 'respondentinfo',
- $this->renderer->homelink(
- $CFG->wwwroot . '/mod/questionnaire/complete.php?' .
- 'id=' . $this->cm->id . '&resume=1',
- $resumesurvey
- )
- );
- }
- }
- }
- }
- }
- return;
- }
-
- // Survey Results Methods.
-
- /**
- * Add the navigation to the responses page.
- * @param int $currrid
- * @param int $currentgroupid
- * @param stdClass $cm
- * @param bool $byresponse
- */
- public function survey_results_navbar_alpha($currrid, $currentgroupid, $cm, $byresponse) {
- global $CFG, $DB;
-
- // Is this questionnaire set to fullname or anonymous?
- $isfullname = $this->respondenttype != 'anonymous';
- if ($isfullname) {
- $responses = $this->get_responses(false, $currentgroupid);
- } else {
- $responses = $this->get_responses();
- }
- if (!$responses) {
- return;
- }
- $total = count($responses);
- if ($total === 0) {
- return;
- }
- $rids = [];
- if ($isfullname) {
- $ridssub = [];
- $ridsuserfullname = [];
- $ridsuserid = [];
- }
- $i = 0;
- $currpos = -1;
- foreach ($responses as $response) {
- array_push($rids, $response->id);
- if ($isfullname) {
- $user = $DB->get_record('user', ['id' => $response->userid]);
- array_push($ridssub, $response->submitted);
- array_push($ridsuserfullname, fullname($user));
- array_push($ridsuserid, $response->userid);
- }
- if ($response->id == $currrid) {
- $currpos = $i;
- }
- $i++;
- }
-
- $url = $CFG->wwwroot . '/mod/questionnaire/report.php?action=vresp&group=' . $currentgroupid . '&individualresponse=1';
- if (!$byresponse) { // Display navbar.
- // Build navbar.
- $navbar = new \stdClass();
- $prevrid = ($currpos > 0) ? $rids[$currpos - 1] : null;
- $nextrid = ($currpos < $total - 1) ? $rids[$currpos + 1] : null;
- $firstrid = $rids[0];
- $lastrid = $rids[$total - 1];
- if ($prevrid != null) {
- $pos = $currpos - 1;
- $title = '';
- $firstuserfullname = '';
- $navbar->firstrespondent = ['url' => ($url . '&rid=' . $firstrid)];
- $navbar->previous = ['url' => ($url . '&rid=' . $prevrid)];
- if ($isfullname) {
- $responsedate = userdate($ridssub[$pos]);
- $title = $ridsuserfullname[$pos];
- // Only add date if more than one response by a student.
- if ($ridsuserid[$pos] == $ridsuserid[$currpos]) {
- $title .= ' | ' . $responsedate;
- }
- $firstuserfullname = $ridsuserfullname[0];
- }
- $navbar->firstrespondent['title'] = $firstuserfullname;
- $navbar->previous['title'] = $title;
- }
- $navbar->respnumber = ['currpos' => ($currpos + 1), 'total' => $total];
- if ($nextrid != null) {
- $pos = $currpos + 1;
- $responsedate = '';
- $title = '';
- $lastuserfullname = '';
- $navbar->lastrespondent = ['url' => ($url . '&rid=' . $lastrid)];
- $navbar->next = ['url' => ($url . '&rid=' . $nextrid)];
- if ($isfullname) {
- $responsedate = userdate($ridssub[$pos]);
- $title = $ridsuserfullname[$pos];
- // Only add date if more than one response by a student.
- if ($ridsuserid[$pos] == $ridsuserid[$currpos]) {
- $title .= ' | ' . $responsedate;
- }
- $lastuserfullname = $ridsuserfullname[$total - 1];
- }
- $navbar->lastrespondent['title'] = $lastuserfullname;
- $navbar->next['title'] = $title;
- }
- $url = $CFG->wwwroot . '/mod/questionnaire/report.php?action=vresp&byresponse=1&group=' . $currentgroupid;
- // Display navbar.
- $navbar->listlink = $url;
-
- // Display a "print this response" icon here in prevision of total removal of tabs in version 2.6.
- $linkname = ' ' . get_string('print', 'questionnaire');
- $url = '/mod/questionnaire/print.php?qid=' . $this->id . '&rid=' . $currrid .
- '&courseid=' . $this->course->id . '&sec=1';
- $title = get_string('printtooltip', 'questionnaire');
- $options = [
- 'menubar' => true,
- 'location' => false,
- 'scrollbars' => true,
- 'resizable' => true,
- 'height' => 600,
- 'width' => 800,
- ];
- $name = 'popup';
- $link = new moodle_url($url);
- $action = new popup_action('click', $link, $name, $options);
- $actionlink = $this->renderer->action_link(
- $link,
- $linkname,
- $action,
- ['title' => $title],
- new pix_icon('t/print', $title)
- );
- $navbar->printaction = $actionlink;
- $this->page->add_to_page('navigationbar', $this->renderer->navigationbar($navbar));
- } else { // Display respondents list.
- $resparr = [];
- for ($i = 0; $i < $total; $i++) {
- if ($isfullname) {
- $responsedate = userdate($ridssub[$i]);
- $resparr[] = '' . $ridsuserfullname[$i] . ' ';
- } else {
- $responsedate = '';
- $resparr[] = '' .
- get_string('response', 'questionnaire') . ($i + 1) . ' ';
- }
- }
- // Table formatting from http://wikkawiki.org/PageAndCategoryDivisionInACategory.
- $total = count($resparr);
- $entries = count($resparr);
- // Default max 3 columns, max 25 lines per column.
- // TODO make this setting customizable.
- $maxlines = 20;
- $maxcols = 3;
- if ($entries >= $maxlines) {
- $colnumber = min(intval($entries / $maxlines), $maxcols);
- } else {
- $colnumber = 1;
- }
- $lines = 0;
- $a = 0;
- // How many lines with an entry in every column do we have?
- while ($entries / $colnumber > 1) {
- $lines++;
- $entries = $entries - $colnumber;
- }
- // Prepare output.
- $respcols = new stdClass();
- for ($i = 0; $i < $colnumber; $i++) {
- $colname = 'respondentscolumn' . $i;
- $respcols->$colname = (object)['respondentlink' => []];
- for ($j = 0; $j < $lines; $j++) {
- $respcols->{$colname}->respondentlink[] = $resparr[$a];
- $a++;
- }
- // The rest of the entries (less than the number of cols).
- if ($entries) {
- $respcols->{$colname}->respondentlink[] = $resparr[$a];
- $entries--;
- $a++;
- }
- }
-
- $this->page->add_to_page('responses', $this->renderer->responselist($respcols));
- }
- }
-
- /**
- * Display responses for current user (your responses).
- * @param int $currrid
- * @param int $userid
- * @param int $instance
- * @param array $resps
- * @param string $reporttype
- * @param string $sid
- */
- public function survey_results_navbar_student($currrid, $userid, $instance, $resps, $reporttype = 'myreport', $sid = '') {
- global $DB;
- $stranonymous = get_string('anonymous', 'questionnaire');
-
- $total = count($resps);
- $rids = [];
- $ridssub = [];
- $ridsusers = [];
- $i = 0;
- $currpos = -1;
- $title = '';
- foreach ($resps as $response) {
- array_push($rids, $response->id);
- array_push($ridssub, $response->submitted);
- $ruser = '';
- if ($reporttype == 'report') {
- if ($this->respondenttype != 'anonymous') {
- if ($user = $DB->get_record('user', ['id' => $response->userid])) {
- $ruser = ' | ' . fullname($user);
- }
- } else {
- $ruser = ' | ' . $stranonymous;
- }
- }
- array_push($ridsusers, $ruser);
- if ($response->id == $currrid) {
- $currpos = $i;
- }
- $i++;
- }
- $prevrid = ($currpos > 0) ? $rids[$currpos - 1] : null;
- $nextrid = ($currpos < $total - 1) ? $rids[$currpos + 1] : null;
-
- if ($reporttype == 'myreport') {
- $url = 'myreport.php?instance=' . $instance . '&user=' . $userid . '&action=vresp&byresponse=1&individualresponse=1';
- } else {
- $url = 'report.php?instance=' . $instance . '&user=' . $userid .
- '&action=vresp&byresponse=1&individualresponse=1&sid=' . $sid;
- }
- $navbar = new \stdClass();
- $displaypos = 1;
- if ($prevrid != null) {
- $title = userdate($ridssub[$currpos - 1] . $ridsusers[$currpos - 1]);
- $navbar->previous = ['url' => ($url . '&rid=' . $prevrid), 'title' => $title];
- }
- for ($i = 0; $i < $currpos; $i++) {
- $title = userdate($ridssub[$i]) . $ridsusers[$i];
- $navbar->prevrespnumbers[] = ['url' => ($url . '&rid=' . $rids[$i]), 'title' => $title, 'respnumber' => $displaypos];
- $displaypos++;
- }
- $navbar->currrespnumber = $displaypos;
- for (++$i; $i < $total; $i++) {
- $displaypos++;
- $title = userdate($ridssub[$i]) . $ridsusers[$i];
- $navbar->nextrespnumbers[] = ['url' => ($url . '&rid=' . $rids[$i]), 'title' => $title, 'respnumber' => $displaypos];
- }
- if ($nextrid != null) {
- $title = userdate($ridssub[$currpos + 1]) . $ridsusers[$currpos + 1];
- $navbar->next = ['url' => ($url . '&rid=' . $nextrid), 'title' => $title];
- }
- $this->page->add_to_page('navigationbar', $this->renderer->usernavigationbar($navbar));
- $this->page->add_to_page('bottomnavigationbar', $this->renderer->usernavigationbar($navbar));
- }
-
- /**
- * Builds HTML for the results for the survey. If a question id and choice id(s) are given, then the results are only calculated
- * for respodants who chose from the choice ids for the given question id. Returns empty string on success, else returns an
- * error string.
- * @param string $rid
- * @param bool $uid
- * @param bool $pdf
- * @param string $currentgroupid
- * @param string $sort
- * @return string|void
- */
- public function survey_results($rid = '', $uid = false, $pdf = false, $currentgroupid = '', $sort = '') {
- global $SESSION, $DB;
-
- $SESSION->questionnaire->noresponses = false;
-
- // Build associative array holding whether each question
- // type has answer choices or not and the table the answers are in
- // TO DO - FIX BELOW TO USE STANDARD FUNCTIONS.
- $haschoices = [];
- $responsetable = [];
- if (!($types = $DB->get_records('questionnaire_question_type', [], 'typeid', 'typeid, has_choices, response_table'))) {
- $errmsg = sprintf(
- '%s [ %s: question_type ]',
- get_string('errortable', 'questionnaire'),
- 'Table'
- );
- return($errmsg);
- }
- foreach ($types as $type) {
- $haschoices[$type->typeid] = $type->has_choices; // TODO is that variable actually used?
- $responsetable[$type->typeid] = $type->response_table;
- }
-
- // Load survey title (and other globals).
- if (empty($this->survey)) {
- $errmsg = get_string('erroropening', 'questionnaire') . " [ ID:{$this->sid} R:";
- return($errmsg);
- }
-
- if (empty($this->questions)) {
- $errmsg = get_string('erroropening', 'questionnaire') . ' ' . 'No questions found.';
- return($errmsg);
- }
-
- // Find total number of survey responses and relevant response ID's.
- if (!empty($rid)) {
- $rids = $rid;
- if (is_array($rids)) {
- $navbar = false;
- } else {
- $navbar = true;
- }
- $numresps = 1;
- } else {
- $navbar = false;
- $userview = optional_param('responsestats', 0, PARAM_ALPHA);
- if ($uid !== false) { // One participant only.
- $rows = $this->get_responses($uid);
- // All participants or all members of a group.
- } else if ($currentgroupid == 0) {
- $rows = $this->get_responses();
- } else { // Members of a specific group.
- $rows = $this->get_responses(false, $currentgroupid);
- }
- if (!$rows) {
- $this->page->add_to_page(
- 'respondentinfo',
- $this->renderer->notification(
- get_string('noresponses', 'questionnaire'),
- \core\output\notification::NOTIFY_ERROR
- )
- );
- $SESSION->questionnaire->noresponses = true;
- return;
- }
- $numresps = count($rows);
- $respondentstring = get_string('responses', 'questionnaire');
- if ($userview === 'y') {
- $respondentstring = get_string('submissions', 'questionnaire');
- }
- if (!$userview) {
- $completedcount = 0;
- $inprogresscount = 0;
-
- foreach ($rows as $row) {
- if ($row->complete === 'y') {
- $completedcount++;
- } else if ($row->complete === 'n') {
- $inprogresscount++;
- }
- }
- $numresps .= ' ' . get_string(
- 'responses_breakdown',
- 'questionnaire',
- [
- 'responses' => $completedcount,
- 'incomplete' => $inprogresscount,
- ]
- );
- }
- $this->page->add_to_page('respondentinfo', ' ' . $respondentstring . ': ' . $numresps . ' ');
- if (empty($rows)) {
- $errmsg = get_string('erroropening', 'questionnaire') . ' ' . get_string('noresponsedata', 'questionnaire');
- return($errmsg);
- }
-
- $rids = [];
- foreach ($rows as $row) {
- array_push($rids, $row->id);
- }
- }
-
- if ($navbar) {
- // Show response navigation bar.
- $this->survey_results_navbar($rid);
- }
-
- $this->page->add_to_page('title', format_string($this->survey->title));
- if ($this->survey->subtitle) {
- $this->page->add_to_page('subtitle', format_string($this->survey->subtitle));
- }
- if ($this->survey->info) {
- $infotext = file_rewrite_pluginfile_urls(
- $this->survey->info,
- 'pluginfile.php',
- $this->context->id,
- 'mod_questionnaire',
- 'info',
- $this->survey->id
- );
- $this->page->add_to_page('addinfo', format_text($infotext, FORMAT_HTML, ['noclean' => true]));
- }
-
- $qnum = 0;
-
- $anonymous = $this->respondenttype == 'anonymous';
-
- foreach ($this->questions as $question) {
- if ($question->type_id == QUESPAGEBREAK) {
- continue;
- }
- if ($question->is_numbered()) {
- $qnum++;
- }
- if (!$pdf) {
- $this->page->add_to_page('responses', $this->renderer->container_start('qn-container'));
- if ($this->questions_autonumbered() && $question->is_numbered()) {
- $this->page->add_to_page('responses', $this->renderer->container_start('qn-info'));
- $this->page->add_to_page('responses', $this->renderer->heading($qnum, 2, 'qn-number'));
- $this->page->add_to_page('responses', $this->renderer->container_end()); // End qn-info.
- }
- $this->page->add_to_page('responses', $this->renderer->container_start('qn-content'));
- }
- // If question text is "empty", i.e. 2 non-breaking spaces were inserted, do not display any question text.
- if ($question->content == '
') {
- $question->content = '';
- }
- if ($pdf) {
- $response = new stdClass();
- if ($this->questions_autonumbered() && $question->is_numbered()) {
- $response->qnum = $qnum;
- }
- $response->qcontent = format_text(
- file_rewrite_pluginfile_urls(
- $question->content,
- 'pluginfile.php',
- $question->context->id,
- 'mod_questionnaire',
- 'question',
- $question->id
- ),
- FORMAT_HTML,
- ['noclean' => true]
- );
- $response->results = $this->renderer->results_output($question, $rids, $sort, $anonymous, $pdf);
- $this->page->add_to_page('responses', $response);
- } else {
- $this->page->add_to_page(
- 'responses',
- $this->renderer->container(
- format_text(
- file_rewrite_pluginfile_urls(
- $question->content,
- 'pluginfile.php',
- $question->context->id,
- 'mod_questionnaire',
- 'question',
- $question->id
- ),
- FORMAT_HTML,
- ['noclean' => true]
- ),
- 'qn-question'
- )
- );
- $this->page->add_to_page('responses', $this->renderer->results_output($question, $rids, $sort, $anonymous));
- $this->page->add_to_page('responses', $this->renderer->container_end()); // End qn-content.
- $this->page->add_to_page('responses', $this->renderer->container_end()); // End qn-container.
- }
- }
-
- return;
- }
-
- /**
- * Get unique list of question types used in the current survey.
- * author: Guy Thomas
- * @param bool $uniquebytable
- * @return array
- */
- protected function get_survey_questiontypes($uniquebytable = false) {
-
- $uniquetypes = [];
- $uniquetables = [];
-
- foreach ($this->questions as $question) {
- $type = $question->type_id;
- $responsetable = $question->responsetable;
- // Build SQL for this question type if not already done.
- if (!$uniquebytable || !in_array($responsetable, $uniquetables)) {
- if (!in_array($type, $uniquetypes)) {
- $uniquetypes[] = $type;
- }
- if (!in_array($responsetable, $uniquetables)) {
- $uniquetables[] = $responsetable;
- }
- }
- }
-
- return $uniquetypes;
- }
-
- /**
- * Return array of all types considered to be choices.
- *
- * @return array
- */
- protected function choice_types() {
- return [QUESRADIO, QUESDROP, QUESCHECK, QUESRATE];
- }
-
- /**
- * Return all the fields to be used for users in questionnaire sql.
- * author: Guy Thomas
- * @return array|string
- */
- protected function user_fields() {
- if (class_exists('\core_user\fields')) {
- $userfieldsarr = \core_user\fields::get_name_fields();
- } else {
- $userfieldsarr = get_all_user_name_fields();
- }
- $userfieldsarr = array_merge($userfieldsarr, ['username', 'department', 'institution']);
- $userfieldsarr = array_merge($userfieldsarr, ['username', 'department', 'institution', 'idnumber']);
- return $userfieldsarr;
- }
-
- /**
- * Get all survey responses in one go.
- * author: Guy Thomas
- * @param string $rid
- * @param string $userid
- * @param bool $groupid
- * @param int $showincompletes
- * @return array
- */
- protected function get_survey_all_responses($rid = '', $userid = '', $groupid = false, $showincompletes = 0) {
- global $DB;
- $uniquetypes = $this->get_survey_questiontypes(true);
- $allresponsessql = "";
- $allresponsesparams = [];
-
- // If a questionnaire is "public", and this is the master course, need to get responses from all instances.
- if ($this->survey_is_public_master()) {
- $qids = array_keys($DB->get_records('questionnaire', ['sid' => $this->sid], 'id') ?? []);
- } else {
- $qids = $this->id;
- }
-
- foreach ($uniquetypes as $type) {
- $question = \mod_questionnaire\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;
- }
-
- $allresponsessql .= " ORDER BY usrid, id";
- $allresponses = $DB->get_recordset_sql($allresponsessql, $allresponsesparams);
- return $allresponses ?? [];
- }
-
- /**
- * Return true if the survey is a 'public' one.
- *
- * @return boolean
- */
- public function survey_is_public() {
- return is_object($this->survey) && ($this->survey->realm == 'public');
- }
-
- /**
- * Return true if the survey is a 'public' one and this is the master instance.
- *
- * @return boolean
- */
- public function survey_is_public_master() {
- return $this->survey_is_public() && ($this->course->id == $this->survey->courseid);
- }
-
- /**
- * Process individual row for csv output
- * @param array $row
- * @param stdClass $resprow resultset row
- * @param int $currentgroupid
- * @param array $questionsbyposition
- * @param int $nbinfocols
- * @param int $numrespcols
- * @param array $options
- * @param array $identityfields
- * @return array
- */
- protected function process_csv_row(
- array &$row,
- stdClass $resprow,
- $currentgroupid,
- array &$questionsbyposition,
- $nbinfocols,
- $numrespcols,
- $options,
- $identityfields
- ) {
- global $DB;
-
- // If using an anonymous response, map users to unique user numbers so that number of unique anonymous users can be seen.
- static $anonumap = [];
-
- $positioned = [];
- $user = new stdClass();
- foreach ($this->user_fields() as $userfield) {
- $user->$userfield = $resprow->$userfield;
- }
- $user->id = $resprow->userid;
- $isanonymous = ($this->respondenttype == 'anonymous');
-
- // Moodle:
- // Get the course name that this questionnaire belongs to.
- if (!$this->survey_is_public()) {
- $courseid = $this->course->id;
- $coursename = $this->course->fullname;
- } else {
- // For a public questionnaire, look for the course that used it.
- $sql = 'SELECT q.id, q.course, c.fullname ' .
- 'FROM {questionnaire_response} qr ' .
- 'INNER JOIN {questionnaire} q ON qr.questionnaireid = q.id ' .
- 'INNER JOIN {course} c ON q.course = c.id ' .
- 'WHERE qr.id = ? AND qr.complete = ? ';
- if ($record = $DB->get_record_sql($sql, [$resprow->rid, 'y'])) {
- $courseid = $record->course;
- $coursename = $record->fullname;
- } else {
- $courseid = $this->course->id;
- $coursename = $this->course->fullname;
- }
- }
-
- // Moodle:
- // Determine if the user is a member of a group in this course or not.
- // TODO - review for performance.
- $groupname = '';
- if (groups_get_activity_groupmode($this->cm, $this->course)) {
- if ($currentgroupid > 0) {
- $groupname = groups_get_group_name($currentgroupid);
- } else {
- if ($user->id) {
- if ($groups = groups_get_all_groups($courseid, $user->id)) {
- foreach ($groups as $group) {
- $groupname .= $group->name . ', ';
- }
- $groupname = substr($groupname, 0, strlen($groupname) - 2);
- } else {
- $groupname = ' (' . get_string('groupnonmembers') . ')';
- }
- }
- }
- }
-
- if ($isanonymous) {
- if (!isset($anonumap[$user->id])) {
- $anonumap[$user->id] = count($anonumap) + 1;
- }
- $fullname = get_string('anonymous', 'questionnaire') . $anonumap[$user->id];
- $username = '';
- $uid = '';
- } else {
- $uid = $user->id;
- $fullname = fullname($user);
- $username = $user->username;
- }
-
- if (in_array('response', $options)) {
- array_push($positioned, $resprow->rid);
- }
- if (in_array('submitted', $options)) {
- // For better compabitility & readability with Excel.
- $submitted = date(get_string('strfdateformatcsv', 'questionnaire'), $resprow->submitted);
- array_push($positioned, $submitted);
- }
- if (in_array('institution', $options)) {
- array_push($positioned, $user->institution);
- }
- if (in_array('department', $options)) {
- array_push($positioned, $user->department);
- }
- if (in_array('course', $options)) {
- array_push($positioned, $coursename);
- }
- if (in_array('group', $options)) {
- array_push($positioned, $groupname);
- }
- if (in_array('id', $options)) {
- array_push($positioned, $uid);
- }
- if (in_array('useridnumber', $options)) {
- array_push($positioned, $user->idnumber);
- }
- if (in_array('fullname', $options)) {
- array_push($positioned, $fullname);
- }
- if (in_array('username', $options)) {
- array_push($positioned, $username);
- }
- if (in_array('complete', $options)) {
- array_push($positioned, $resprow->complete);
- }
- foreach ($identityfields as $field) {
- array_push($positioned, $resprow->$field);
- }
-
- for ($c = $nbinfocols; $c < $numrespcols; $c++) {
- if (isset($row[$c])) {
- $positioned[] = $row[$c];
- } else if (isset($questionsbyposition[$c])) {
- $question = $questionsbyposition[$c];
- $qtype = intval($question->type_id);
- if ($qtype === QUESCHECK) {
- $positioned[] = '0';
- } else {
- $positioned[] = null;
- }
- } else {
- $positioned[] = null;
- }
- }
- return $positioned;
- }
-
- /**
- * Exports the results of a survey to an array.
- * @param int $currentgroupid
- * @param string $rid
- * @param string $userid
- * @param int $choicecodes
- * @param int $choicetext
- * @param int $showincompletes
- * @param int $rankaverages
- * @return array
- */
- public function generate_csv(
- $currentgroupid,
- $rid = '',
- $userid = '',
- $choicecodes = 1,
- $choicetext = 0,
- $showincompletes = 0,
- $rankaverages = 0
- ) {
- global $DB;
-
- raise_memory_limit('1G');
-
- $output = [];
- $stringother = get_string('other', 'questionnaire');
-
- $config = get_config('questionnaire', 'downloadoptions');
- $options = empty($config) ? [] : explode(',', $config);
- if ($showincompletes == 1) {
- $options[] = 'complete';
- }
- $columns = [];
- $types = [];
- foreach ($options as $option) {
- if (in_array($option, ['response', 'submitted', 'id', 'useridnumber'])) {
- $columns[] = get_string($option, 'questionnaire');
- $types[] = 0;
- } else if ($option == 'useridentityfields') {
- // Ignore option.
- continue;
- } else {
- $columns[] = get_string($option);
- $types[] = 1;
- }
- }
- $identityfields = $this->get_identity_fields($options);
- foreach ($identityfields as $field) {
- $columns[] = \core_user\fields::get_display_name($field);
- }
- $nbinfocols = count($columns);
-
- $idtocsvmap = [
- '0', // 0: unused
- '0', // 1: bool -> boolean
- '1', // 2: text -> string
- '1', // 3: essay -> string
- '0', // 4: radio -> string
- '0', // 5: check -> string
- '0', // 6: dropdn -> string
- '0', // 7: rating -> number
- '0', // 8: rate -> number
- '1', // 9: date -> string
- '0', // 10: numeric -> number.
- '0', // 11: slider -> number.
- ];
-
- if (!$survey = $DB->get_record('questionnaire_survey', ['id' => $this->survey->id])) {
- throw new \moodle_exception('surveynotexists', 'mod_questionnaire');
- }
-
- // Get all responses for this survey in one go.
- $allresponsesrs = $this->get_survey_all_responses($rid, $userid, $currentgroupid, $showincompletes);
-
- // Do we have any questions of type RADIO, DROP, CHECKBOX OR RATE? If so lets get all their choices in one go.
- $choicetypes = $this->choice_types();
-
- // Get unique list of question types used in this survey.
- $uniquetypes = $this->get_survey_questiontypes();
-
- if (count(array_intersect($choicetypes, $uniquetypes)) > 0) {
- $choiceparams = [$this->survey->id];
- $choicesql = "
- SELECT DISTINCT c.id as cid, q.id as qid, q.precise AS precise, q.name, c.content
- FROM {questionnaire_question} q
- JOIN {questionnaire_quest_choice} c ON question_id = q.id
- WHERE q.surveyid = ? ORDER BY cid ASC
- ";
- $choicerecords = $DB->get_records_sql($choicesql, $choiceparams);
- $choicesbyqid = [];
- if (!empty($choicerecords)) {
- // Hash the options by question id.
- foreach ($choicerecords as $choicerecord) {
- if (!isset($choicesbyqid[$choicerecord->qid])) {
- // New question id detected, intialise empty array to store choices.
- $choicesbyqid[$choicerecord->qid] = [];
- }
- $choicesbyqid[$choicerecord->qid][$choicerecord->cid] = $choicerecord;
- }
- }
- }
-
- $num = 1;
-
- $questionidcols = [];
-
- foreach ($this->questions as $question) {
- // Skip questions that aren't response capable.
- if (!isset($question->responsetype)) {
- continue;
- }
- // Establish the table's field names.
- $qid = $question->id;
- $qpos = $question->position;
- $col = $question->name;
- $type = $question->type_id;
- if (in_array($type, $choicetypes)) {
- /* single or multiple or rate */
- if (!isset($choicesbyqid[$qid])) {
- throw new coding_exception('Choice question has no choices!', 'question id ' . $qid . ' of type ' . $type);
- }
- $choices = $choicesbyqid[$qid];
-
- switch ($type) {
- case QUESRADIO: // Single.
- case QUESDROP:
- $columns[][$qpos] = $col;
- $questionidcols[][$qpos] = $qid;
- array_push($types, $idtocsvmap[$type]);
- $thisnum = 1;
- foreach ($choices as $choice) {
- $content = $choice->content;
- // If "Other" add a column for the actual "other" text entered.
- if (\mod_questionnaire\question\choice::content_is_other_choice($content)) {
- $col = $choice->name . '_' . $stringother;
- $columns[][$qpos] = $col;
- $questionidcols[][$qpos] = null;
- array_push($types, '0');
- }
- }
- break;
-
- case QUESCHECK: // Multiple.
- $thisnum = 1;
- foreach ($choices as $choice) {
- $content = $choice->content;
- $modality = '';
- $contents = questionnaire_choice_values($content);
- if ($contents->modname) {
- $modality = $contents->modname;
- } else if ($contents->title) {
- $modality = $contents->title;
- } else {
- $modality = strip_tags($contents->text);
- }
- $col = $choice->name . '->' . $modality;
- $columns[][$qpos] = $col;
- $questionidcols[][$qpos] = $qid . '_' . $choice->cid;
- array_push($types, '0');
- // If "Other" add a column for the "other" checkbox.
- // Then add a column for the actual "other" text entered.
- if (\mod_questionnaire\question\choice::content_is_other_choice($content)) {
- $content = $stringother;
- $col = $choice->name . '->[' . $content . ']';
- $columns[][$qpos] = $col;
- $questionidcols[][$qpos] = null;
- array_push($types, '0');
- }
- }
- break;
-
- case QUESRATE: // Rate.
- foreach ($choices as $choice) {
- $nameddegrees = 0;
- $modality = '';
- $content = $choice->content;
- $osgood = false;
- if (\mod_questionnaire\question\rate::type_is_osgood_rate_scale($choice->precise)) {
- $osgood = true;
- }
- if (preg_match("/^[0-9]{1,3}=/", $content, $ndd)) {
- $nameddegrees++;
- } else {
- if ($osgood) {
- [$contentleft, $contentright] = array_merge(preg_split('/[|]/', $content), [' ']);
- $contents = questionnaire_choice_values($contentleft);
- if ($contents->title) {
- $contentleft = $contents->title;
- }
- $contents = questionnaire_choice_values($contentright);
- if ($contents->title) {
- $contentright = $contents->title;
- }
- $modality = strip_tags($contentleft . '|' . $contentright);
- $modality = preg_replace("/[\r\n\t]/", ' ', $modality);
- } else {
- $contents = questionnaire_choice_values($content);
- if ($contents->modname) {
- $modality = $contents->modname;
- } else if ($contents->title) {
- $modality = $contents->title;
- } else {
- $modality = strip_tags($contents->text);
- $modality = preg_replace("/[\r\n\t]/", ' ', $modality);
- }
- }
- $col = $choice->name . '->' . $modality;
- $columns[][$qpos] = $col;
- $questionidcols[][$qpos] = $qid . '_' . $choice->cid;
- array_push($types, $idtocsvmap[$type]);
- }
- }
- break;
- }
- } else {
- $columns[][$qpos] = $col;
- $questionidcols[][$qpos] = $qid;
- array_push($types, $idtocsvmap[$type]);
- }
- $num++;
- }
-
- array_push($output, $columns);
- $numrespcols = count($output[0]); // Number of columns used for storing question responses.
-
- // Flatten questionidcols.
- $tmparr = [];
- for ($c = 0; $c < $nbinfocols; $c++) {
- $tmparr[] = null; // Pad with non question columns.
- }
- foreach ($questionidcols as $i => $positions) {
- foreach ($positions as $position => $qid) {
- $tmparr[] = $qid;
- }
- }
- $questionidcols = $tmparr;
-
- // Create array of question positions hashed by question / question + choiceid.
- // And array of questions hashed by position.
- $questionpositions = [];
- $questionsbyposition = [];
- $p = 0;
- foreach ($questionidcols as $qid) {
- if ($qid === null) {
- // This is just padding, skip.
- $p++;
- continue;
- }
- $questionpositions[$qid] = $p;
- if (strpos($qid, '_') !== false) {
- $tmparr = explode('_', $qid);
- $questionid = $tmparr[0];
- } else {
- $questionid = $qid;
- }
- $questionsbyposition[$p] = $this->questions[$questionid];
- $p++;
- }
-
- $formatoptions = new stdClass();
- $formatoptions->filter = false; // To prevent any filtering in CSV output.
-
- if ($rankaverages) {
- $averages = [];
- $rids = [];
- $allresponsesrs2 = $this->get_survey_all_responses($rid, $userid, $currentgroupid);
- foreach ($allresponsesrs2 as $responserow) {
- if (!isset($rids[$responserow->rid])) {
- $rids[$responserow->rid] = $responserow->rid;
- }
- }
- }
-
- // Get textual versions of responses, add them to output at the correct col position.
- $prevresprow = false; // Previous response row.
- $row = [];
- if ($rankaverages) {
- $averagerow = [];
- }
- $useridentityfields = [];
- foreach ($allresponsesrs as $responserow) {
- $rid = $responserow->rid;
- $qid = $responserow->question_id;
-
- // It's possible for a response to exist for a deleted question. Ignore these.
- if (!isset($this->questions[$qid])) {
- continue;
- }
-
- if (!empty($identityfields)) {
- // Get identity fields for user.
- if (isset($useridentityfields[$responserow->userid])) {
- $customfields = $useridentityfields[$responserow->userid];
- } else {
- $customfields = self::get_user_identity_fields($this->context, $responserow->userid);
- $useridentityfields[$responserow->userid] = $customfields;
- }
-
- // Set profile fields for user in response row.
- foreach ($identityfields as $field) {
- $responserow->{$field} = $customfields->{$field};
- }
- }
-
- $question = $this->questions[$qid];
- $qtype = intval($question->type_id);
- if ($rankaverages) {
- if ($qtype === QUESRATE) {
- if (empty($averages[$qid])) {
- $results = $this->questions[$qid]->responsetype->get_results($rids);
- foreach ($results as $qresult) {
- $averages[$qid][$qresult->id] = $qresult->average;
- }
- }
- }
- }
- $questionobj = $this->questions[$qid];
-
- if ($prevresprow !== false && $prevresprow->rid !== $rid) {
- $output[] = $this->process_csv_row(
- $row,
- $prevresprow,
- $currentgroupid,
- $questionsbyposition,
- $nbinfocols,
- $numrespcols,
- $options,
- $identityfields
- );
- $row = [];
- }
-
- if ($qtype === QUESRATE || $qtype === QUESCHECK) {
- $key = $qid . '_' . $responserow->choice_id;
- $position = $questionpositions[$key];
- if ($qtype === QUESRATE) {
- $choicetxt = $responserow->rankvalue;
- if ($rankaverages) {
- $averagerow[$position] = $averages[$qid][$responserow->choice_id];
- }
- } else {
- $content = $choicesbyqid[$qid][$responserow->choice_id]->content;
- if (\mod_questionnaire\question\choice::content_is_other_choice($content)) {
- // If this is an "other" column, put the text entered in the next position.
- $row[$position + 1] = $responserow->response;
- $choicetxt = empty($responserow->choice_id) ? '0' : '1';
- } else if (!empty($responserow->choice_id)) {
- $choicetxt = '1';
- } else {
- $choicetxt = '0';
- }
- }
- $responsetxt = $choicetxt;
- $row[$position] = $responsetxt;
- } else {
- $position = $questionpositions[$qid];
- if ($questionobj->has_choices()) {
- // This is choice type question, so process as so.
- $c = 0;
- if (in_array(intval($question->type_id), $choicetypes)) {
- $choices = $choicesbyqid[$qid];
- // Get position of choice.
- foreach ($choices as $choice) {
- $c++;
- if ($responserow->choice_id === $choice->cid) {
- break;
- }
- }
- }
-
- $content = $choicesbyqid[$qid][$responserow->choice_id]->content;
- if (\mod_questionnaire\question\choice::content_is_other_choice($content)) {
- // If this has an "other" text, use it.
- $responsetxt = \mod_questionnaire\question\choice::content_other_choice_display($content);
- $responsetxt1 = $responserow->response;
- } else if (($choicecodes == 1) && ($choicetext == 1)) {
- $responsetxt = $c . ' : ' . $content;
- } else if ($choicecodes == 1) {
- $responsetxt = $c;
- } else {
- $responsetxt = $content;
- }
- } else if (intval($qtype) === QUESYESNO) {
- // At this point, the boolean responses are returned as characters in the "response"
- // field instead of "choice_id" for csv exports (CONTRIB-6436).
- $responsetxt = $responserow->response === 'y' ? "1" : "0";
- } else {
- // Strip potential html tags from modality name.
- $responsetxt = $responserow->response;
- if (!empty($responsetxt)) {
- $responsetxt = $responserow->response;
- $responsetxt = strip_tags($responsetxt);
- $responsetxt = preg_replace("/[\r\n\t]/", ' ', $responsetxt);
- }
- }
- $row[$position] = $responsetxt;
- // Check for "other" text and set it to the next position if present.
- if (!empty($responsetxt1)) {
- $responsetxt1 = preg_replace("/[\r\n\t]/", ' ', $responsetxt1);
- $row[$position + 1] = $responsetxt1;
- unset($responsetxt1);
- }
- }
-
- $prevresprow = $responserow;
- }
-
- if ($prevresprow !== false) {
- // Add final row to output. May not exist if no response data was ever present.
- $output[] = $this->process_csv_row(
- $row,
- $prevresprow,
- $currentgroupid,
- $questionsbyposition,
- $nbinfocols,
- $numrespcols,
- $options,
- $identityfields
- );
- }
-
- // Add averages row if appropriate.
- if ($rankaverages) {
- $summaryrow = [];
- $summaryrow[0] = get_string('averagesrow', 'questionnaire');
- $i = 1;
- for ($i = 1; $i < $nbinfocols; $i++) {
- $summaryrow[$i] = '';
- }
- $pos = 0;
- for ($i = $nbinfocols; $i < $numrespcols; $i++) {
- $summaryrow[$i] = isset($averagerow[$i]) ? $averagerow[$i] : '';
- }
- $output[] = $summaryrow;
- }
-
- // Change table headers to incorporate actual question numbers.
- $numquestion = 0;
- $oldkey = 0;
-
- for ($i = $nbinfocols; $i < $numrespcols; $i++) {
- $sep = '';
- $thisoutput = current($output[0][$i]);
- $thiskey = key($output[0][$i]);
- // Case of unnamed rate single possible answer (full stop char is used for support).
- if (strstr($thisoutput, '->.')) {
- $thisoutput = str_replace('->.', '', $thisoutput);
- }
- // If variable is not named no separator needed between Question number and potential sub-variables.
- if (
- $thisoutput == '' ||
- strstr($thisoutput, '->.') ||
- substr($thisoutput, 0, 2) == '->' ||
- substr($thisoutput, 0, 1) == '_'
- ) {
- $sep = '';
- } else {
- $sep = '_';
- }
- if ($thiskey > $oldkey) {
- $oldkey = $thiskey;
- $numquestion++;
- }
- // Abbreviated modality name in multiple or rate questions (COLORS->blue=the color of the sky...).
- $pos = strpos($thisoutput, '=');
- if ($pos) {
- $thisoutput = substr($thisoutput, 0, $pos);
- }
- $out = 'Q' . sprintf("%02d", $numquestion) . $sep . $thisoutput;
- $output[0][$i] = $out;
- }
- return $output;
- }
-
- /**
- * Function to move a question to a new position.
- * Adapted from feedback plugin.
- *
- * @param int $moveqid The id of the question to be moved.
- * @param int $movetopos The position to move question to.
- *
- */
- public function move_question($moveqid, $movetopos) {
- global $DB;
-
- $questions = $this->questions;
- $movequestion = $this->questions[$moveqid];
-
- if (is_array($questions)) {
- $index = 1;
- foreach ($questions as $question) {
- if ($index == $movetopos) {
- $index++;
- }
- if ($question->id == $movequestion->id) {
- $movequestion->position = $movetopos;
- $DB->update_record("questionnaire_question", $movequestion);
- continue;
- }
- $question->position = $index;
- $DB->update_record("questionnaire_question", $question);
- $index++;
- }
- return true;
- }
- return false;
- }
-
- /**
- * Render the response analysis page.
- * @param int $rid
- * @param array $resps
- * @param bool $compare
- * @param bool $isgroupmember
- * @param bool $allresponses
- * @param int $currentgroupid
- * @param array $filteredsections
- * @return array|string
- */
- public function response_analysis(
- $rid,
- $resps,
- $compare,
- $isgroupmember,
- $allresponses,
- $currentgroupid,
- $filteredsections = null
- ) {
- global $DB, $CFG;
- require_once($CFG->libdir . '/tablelib.php');
- require_once($CFG->dirroot . '/mod/questionnaire/drawchart.php');
-
- // Find if there are any feedbacks in this questionnaire.
- $sql = "SELECT * FROM {questionnaire_fb_sections} WHERE surveyid = ? AND section IS NOT NULL";
- if (!$fbsections = $DB->get_records_sql($sql, [$this->survey->id])) {
- return '';
- }
-
- $action = optional_param('action', 'vall', PARAM_ALPHA);
-
- $resp = $DB->get_record('questionnaire_response', ['id' => $rid]);
- if (!empty($resp)) {
- $userid = $resp->userid;
- $user = $DB->get_record('user', ['id' => $userid]);
- if (!empty($user)) {
- if ($this->respondenttype == 'anonymous') {
- $ruser = '- ' . get_string('anonymous', 'questionnaire') . ' -';
- } else {
- $ruser = fullname($user);
- }
- }
- }
- // Available group modes (0 = no groups; 1 = separate groups; 2 = visible groups).
- $groupmode = groups_get_activity_groupmode($this->cm, $this->course);
- $groupname = get_string('allparticipants');
- if ($groupmode > 0) {
- if ($currentgroupid > 0) {
- $groupname = groups_get_group_name($currentgroupid);
- } else {
- $groupname = get_string('allparticipants');
- }
- }
- if ($this->survey->feedbackscores) {
- $table = new html_table();
- $table->size = [null, null];
- $table->align = ['left', 'right', 'right'];
- $table->head = [];
- $table->wrap = [];
- if ($compare) {
- $table->head = [get_string('feedbacksection', 'questionnaire'), $ruser, $groupname];
- } else {
- $table->head = [get_string('feedbacksection', 'questionnaire'), $groupname];
- }
- }
-
- $fbsectionsnb = array_keys($fbsections);
- $numsections = count($fbsections);
-
- // Get all response ids for all respondents.
- $rids = [];
- foreach ($resps as $key => $resp) {
- $rids[] = $key;
- }
- $nbparticipants = count($rids);
- $responsescores = [];
-
- // Calculate max score per question in questionnaire.
- $qmax = [];
- $maxtotalscore = 0;
- foreach ($this->questions as $question) {
- $qid = $question->id;
- if ($question->valid_feedback()) {
- $qmax[$qid] = $question->get_feedback_maxscore();
- $maxtotalscore += $qmax[$qid];
- // Get all the feedback scores for this question.
- $responsescores[$qid] = $question->get_feedback_scores($rids);
- }
- }
- // Just in case no values have been entered in the various questions possible answers field.
- if ($maxtotalscore === 0) {
- return '';
- }
- $feedbackmessages = [];
-
- // Get individual scores for each question in this responses set.
- $qscore = [];
- $allqscore = [];
-
- if (!$allresponses && $groupmode != 0) {
- $nbparticipants = max(1, $nbparticipants - !$isgroupmember);
- }
- foreach ($responsescores as $qid => $responsescore) {
- if (!empty($responsescore)) {
- foreach ($responsescore as $rrid => $response) {
- // If this is current user's response OR if current user is viewing another group's results.
- if ($rrid == $rid || $allresponses) {
- if (!isset($qscore[$qid])) {
- $qscore[$qid] = 0;
- }
- $qscore[$qid] = $response->score;
- }
- // Course score.
- if (!isset($allqscore[$qid])) {
- $allqscore[$qid] = 0;
- }
- // Only add current score if conditions below are met.
- if ($groupmode == 0 || $isgroupmember || (!$isgroupmember && $rrid != $rid) || $allresponses) {
- $allqscore[$qid] += $response->score;
- }
- }
- }
- }
- $totalscore = array_sum($qscore);
- $scorepercent = round($totalscore / $maxtotalscore * 100);
- $oppositescorepercent = 100 - $scorepercent;
- $alltotalscore = array_sum($allqscore);
- $allscorepercent = round($alltotalscore / $nbparticipants / $maxtotalscore * 100);
-
- // No need to go further if feedback is global, i.e. only relying on total score.
- if ($this->survey->feedbacksections == 1) {
- $sectionid = $fbsectionsnb[0];
- $sectionlabel = $fbsections[$sectionid]->sectionlabel;
-
- $sectionheading = $fbsections[$sectionid]->sectionheading;
- $labels = [];
- if ($feedbacks = $DB->get_records('questionnaire_feedback', ['sectionid' => $sectionid])) {
- foreach ($feedbacks as $feedback) {
- if ($feedback->feedbacklabel != '') {
- $labels[] = $feedback->feedbacklabel;
- }
- }
- }
- $feedback = $DB->get_record_select(
- 'questionnaire_feedback',
- 'sectionid = ? AND minscore <= ? AND ? < maxscore',
- [$sectionid, $scorepercent, $scorepercent]
- );
-
- // To eliminate all potential % chars in heading text (might interfere with the sprintf function).
- $sectionheading = str_replace('%', '', $sectionheading);
- // Replace section heading placeholders with their actual value (if any).
- $original = ['$scorepercent', '$oppositescorepercent'];
- $result = ['%s%%', '%s%%'];
- $sectionheading = str_replace($original, $result, $sectionheading);
- $sectionheading = sprintf($sectionheading, $scorepercent, $oppositescorepercent);
- $sectionheading = file_rewrite_pluginfile_urls(
- $sectionheading,
- 'pluginfile.php',
- $this->context->id,
- 'mod_questionnaire',
- 'sectionheading',
- $sectionid
- );
- $feedbackmessages[] = $this->renderer->box_start();
- $feedbackmessages[] = format_text($sectionheading, FORMAT_HTML, ['noclean' => true]);
- $feedbackmessages[] = $this->renderer->box_end();
-
- if (!empty($feedback->feedbacktext)) {
- // Clean the text, ready for display.
- $formatoptions = new stdClass();
- $formatoptions->noclean = true;
- $feedbacktext = file_rewrite_pluginfile_urls(
- $feedback->feedbacktext,
- 'pluginfile.php',
- $this->context->id,
- 'mod_questionnaire',
- 'feedback',
- $feedback->id
- );
- $feedbacktext = format_text($feedbacktext, $feedback->feedbacktextformat, $formatoptions);
- $feedbackmessages[] = $this->renderer->box_start();
- $feedbackmessages[] = $feedbacktext;
- $feedbackmessages[] = $this->renderer->box_end();
- }
- $score = [$scorepercent, 100 - $scorepercent];
- $allscore = null;
- if ($compare || $allresponses) {
- $allscore = [$allscorepercent, 100 - $allscorepercent];
- }
- $usergraph = get_config('questionnaire', 'usergraph');
- if ($usergraph && $this->survey->chart_type) {
- $this->page->add_to_page(
- 'feedbackcharts',
- draw_chart(
- $feedbacktype = 'global',
- $labels,
- $groupname,
- $allresponses,
- $this->survey->chart_type,
- $score,
- $allscore,
- $sectionlabel
- )
- );
- }
- // Display class or group score. Pending chart library decision to display?
- // Find out if this feedback sectionlabel has a pipe separator.
- $lb = explode("|", $sectionlabel);
- $oppositescore = '';
- $oppositeallscore = '';
- if (count($lb) > 1) {
- $sectionlabel = $lb[0] . ' | ' . $lb[1];
- $oppositescore = ' | ' . $score[1] . '%';
- $oppositeallscore = ' | ' . $allscore[1] . '%';
- }
- if ($this->survey->feedbackscores) {
- $table = $table ?? new html_table();
- if ($compare) {
- $table->data[] = [$sectionlabel, $score[0] . '%' . $oppositescore, $allscore[0] . '%' . $oppositeallscore];
- } else {
- $table->data[] = [$sectionlabel, $allscore[0] . '%' . $oppositeallscore];
- }
-
- $this->page->add_to_page('feedbackscores', html_writer::table($table));
- }
-
- return $feedbackmessages;
- }
-
- // Now process scores for more than one section.
-
- // Initialize scores and maxscores to 0.
- $score = [];
- $allscore = [];
- $maxscore = [];
- $scorepercent = [];
- $allscorepercent = [];
- $oppositescorepercent = [];
- $alloppositescorepercent = [];
- $chartlabels = [];
- // Sections where all questions are unseen because of the $advdependencies.
- $nanscores = [];
-
- for ($i = 1; $i <= $numsections; $i++) {
- $score[$i] = 0;
- $allscore[$i] = 0;
- $maxscore[$i] = 0;
- $scorepercent[$i] = 0;
- }
-
- for ($section = 1; $section <= $numsections; $section++) {
- // Get feedback messages only for this sections.
- if (($filteredsections != null) && !in_array($section, $filteredsections)) {
- continue;
- }
- foreach ($fbsections as $key => $fbsection) {
- if ($fbsection->section == $section) {
- $feedbacksectionid = $key;
- $scorecalculation = section::decode_scorecalculation($fbsection->scorecalculation);
- if (empty($scorecalculation) && !is_array($scorecalculation)) {
- $scorecalculation = [];
- }
- $sectionheading = $fbsection->sectionheading;
- $imageid = $fbsection->id;
- $chartlabels[$section] = $fbsection->sectionlabel;
- }
- }
- foreach ($scorecalculation as $qid => $key) {
- // Just in case a question pertaining to a section has been deleted or made not required
- // after being included in scorecalculation.
- if (isset($qscore[$qid])) {
- $key = empty($key) ? 1 : $key;
- $score[$section] += round($qscore[$qid] * $key);
- $maxscore[$section] += round($qmax[$qid] * $key);
- if ($compare || $allresponses) {
- $allscore[$section] += round($allqscore[$qid] * $key);
- }
- }
- }
-
- if ($maxscore[$section] == 0) {
- array_push($nanscores, $section);
- }
-
- $scorepercent[$section] = ($maxscore[$section] > 0) ? (round($score[$section] / $maxscore[$section] * 100)) : 0;
- $oppositescorepercent[$section] = 100 - $scorepercent[$section];
-
- if (($compare || $allresponses) && $nbparticipants != 0) {
- $allscorepercent[$section] = ($maxscore[$section] > 0) ? (round(($allscore[$section] / $nbparticipants) /
- $maxscore[$section] * 100)) : 0;
- $alloppositescorepercent[$section] = 100 - $allscorepercent[$section];
- }
-
- if (!$allresponses) {
- if (is_nan($scorepercent[$section])) {
- // Info: all questions of $section are unseen
- // -> $scorepercent[$section] = round($score[$section] / $maxscore[$section] * 100) == NAN
- // -> $maxscore[$section] == 0 -> division by zero
- // $DB->get_record_select(...) fails, don't show feedbackmessage.
- continue;
- }
- // To eliminate all potential % chars in heading text (might interfere with the sprintf function).
- $sectionheading = str_replace('%', '', $sectionheading);
-
- // Replace section heading placeholders with their actual value (if any).
- $original = ['$scorepercent', '$oppositescorepercent'];
- $result = ["$scorepercent[$section]%", "$oppositescorepercent[$section]%"];
- $sectionheading = str_replace($original, $result, $sectionheading);
- $formatoptions = new stdClass();
- $formatoptions->noclean = true;
- $sectionheading = file_rewrite_pluginfile_urls(
- $sectionheading,
- 'pluginfile.php',
- $this->context->id,
- 'mod_questionnaire',
- 'sectionheading',
- $imageid
- );
- $sectionheading = format_text($sectionheading, 1, $formatoptions);
- $feedbackmessages[] = $this->renderer->box_start('reportQuestionTitle');
- $feedbackmessages[] = format_text($sectionheading, FORMAT_HTML, $formatoptions);
- $feedback = $DB->get_record_select(
- 'questionnaire_feedback',
- 'sectionid = ? AND minscore <= ? AND ? < maxscore',
- [$feedbacksectionid, $scorepercent[$section], $scorepercent[$section]],
- 'id,feedbacktext,feedbacktextformat'
- );
- $feedbackmessages[] = $this->renderer->box_end();
- if (!empty($feedback->feedbacktext)) {
- // Clean the text, ready for display.
- $formatoptions = new stdClass();
- $formatoptions->noclean = true;
- $feedbacktext = file_rewrite_pluginfile_urls(
- $feedback->feedbacktext,
- 'pluginfile.php',
- $this->context->id,
- 'mod_questionnaire',
- 'feedback',
- $feedback->id
- );
- $feedbacktext = format_text($feedbacktext, $feedback->feedbacktextformat, $formatoptions);
- $feedbackmessages[] = $this->renderer->box_start('feedbacktext');
- $feedbackmessages[] = $feedbacktext;
- $feedbackmessages[] = $this->renderer->box_end();
- }
- }
- }
-
- // Display class or group score.
- switch ($action) {
- case 'vallasort':
- asort($allscore);
- break;
- case 'vallarsort':
- arsort($allscore);
- break;
- default:
- }
-
- if ($this->survey->feedbackscores) {
- foreach ($allscore as $key => $sc) {
- if (isset($chartlabels[$key])) {
- $lb = explode("|", $chartlabels[$key]);
- $oppositescore = '';
- $oppositeallscore = '';
- if (count($lb) > 1) {
- $sectionlabel = $lb[0] . ' | ' . $lb[1];
- $oppositescore = ' | ' . $oppositescorepercent[$key] . '%';
- $oppositeallscore = ' | ' . $alloppositescorepercent[$key] . '%';
- } else {
- $sectionlabel = $chartlabels[$key];
- }
- // If all questions of $section are unseen then don't show feedbackscores for this section.
- if ($compare && !is_nan($scorepercent[$key])) {
- $table = $table ?? new html_table();
- $table->data[] = [
- $sectionlabel,
- $scorepercent[$key] . '%' . $oppositescore,
- $allscorepercent[$key] . '%' . $oppositeallscore,
- ];
- } else if (isset($allscorepercent[$key]) && !is_nan($allscorepercent[$key])) {
- $table = $table ?? new html_table();
- $table->data[] = [$sectionlabel, $allscorepercent[$key] . '%' . $oppositeallscore];
- }
- }
- }
- }
- $usergraph = get_config('questionnaire', 'usergraph');
-
- // Don't show feedbackcharts for sections in $nanscores -> remove sections from array.
- foreach ($nanscores as $val) {
- unset($chartlabels[$val]);
- unset($scorepercent[$val]);
- unset($allscorepercent[$val]);
- }
-
- if ($usergraph && $this->survey->chart_type) {
- $this->page->add_to_page(
- 'feedbackcharts',
- draw_chart(
- 'sections',
- array_values($chartlabels),
- $groupname,
- $allresponses,
- $this->survey->chart_type,
- array_values($scorepercent),
- array_values($allscorepercent),
- $sectionlabel
- )
- );
- }
- if ($this->survey->feedbackscores) {
- $this->page->add_to_page('feedbackscores', html_writer::table($table));
- }
-
- return $feedbackmessages;
- }
-
- // Mobile support area.
-
- /**
- * Save the data from the mobile app.
- * @param int $userid
- * @param int $sec
- * @param bool $completed
- * @param int $rid
- * @param bool $submit
- * @param string $action
- * @param array $responses
- * @return array
- */
- public function save_mobile_data($userid, $sec, $completed, $rid, $submit, $action, array $responses) {
- global $DB, $CFG; // Do not delete "$CFG".
-
- $ret = [];
- $response = $this->build_response_from_appdata((object)$responses, $sec);
- $response->sec = $sec;
- $response->rid = $rid;
- $response->id = $rid;
-
- if ($action == 'nextpage') {
- $result = $this->next_page_action($response, $userid);
- if (is_string($result)) {
- $ret['warnings'] = $result;
- } else {
- $ret['nextpagenum'] = $result;
- }
- } else if ($action == 'previouspage') {
- $ret['nextpagenum'] = $this->previous_page_action($response, $userid);
- } else if (!$completed) {
- // If reviewing a completed questionnaire, don't insert a response.
- $msg = $this->response_check_format($response->sec, $response);
- if (empty($msg)) {
- $rid = $this->response_insert($response, $userid);
- } else {
- $ret['warnings'] = $msg;
- $ret['response'] = $response;
- }
- }
-
- if ($submit && (!isset($ret['warnings']) || empty($ret['warnings']))) {
- $this->commit_submission_response($rid, $userid);
- }
- return $ret;
- }
-
- /**
- * Get all of the areas that can have files.
- * @return array
- * @throws dml_exception
- */
- public function get_all_file_areas() {
- global $DB;
-
- $areas = [];
- $areas['info'] = $this->sid;
- $areas['thankbody'] = $this->sid;
-
- // Add question areas.
- if (empty($this->questions)) {
- $this->add_questions();
- }
- $areas['question'] = [];
- foreach ($this->questions as $question) {
- $areas['question'][] = $question->id;
- }
-
- // Add feedback areas.
- $areas['feedbacknotes'] = $this->sid;
- $fbsections = $DB->get_records('questionnaire_fb_sections', ['surveyid' => $this->sid]);
- if (!empty($fbsections)) {
- $areas['sectionheading'] = [];
- foreach ($fbsections as $section) {
- $areas['sectionheading'][] = $section->id;
- $feedbacks = $DB->get_records('questionnaire_feedback', ['sectionid' => $section->id]);
- if (!empty($feedbacks)) {
- $areas['feedback'] = [];
- foreach ($feedbacks as $feedback) {
- $areas['feedback'][] = $feedback->id;
- }
- }
- }
- }
-
- return $areas;
- }
-
- /**
- * Gets the identity fields.
- *
- * @param array $options
- * @return array
- */
- protected function get_identity_fields($options) {
- $fields = !in_array('useridentityfields', $options) || $this->respondenttype == 'anonymous' ? [] :
- \core_user\fields::get_identity_fields($this->context);
- return $fields;
- }
-
- /**
- * Gets the identity fields values for a user.
- *
- * @param object $context
- * @param int $userid
- * @return array
- */
- public static function get_user_identity_fields($context, $userid) {
- global $DB;
-
- $fields = \core_user\fields::for_identity($context);
- [
- 'selects' => $selects,
- 'joins' => $joins,
- 'params' => $params
- ] = (array)$fields->get_sql('u', false, '', '', false);
- $sql = "SELECT $selects
- FROM {user} u $joins
- WHERE u.id = ?";
- $row = $DB->get_record_sql($sql, array_merge($params, [$userid]));
- return $row;
- }
-
- /**
- * Output the questionnaire information.
- *
- * @return string
- */
- public function view_information() {
- $messages = [];
-
- if (isset($this->qtype)) {
- switch ($this->qtype) {
- case QUESTIONNAIREUNLIMITED:
- $typestring = get_string('unlimited', 'questionnaire');
- break;
- case QUESTIONNAIREONCE:
- $typestring = get_string('once', 'questionnaire');
- break;
- case QUESTIONNAIREDAILY:
- $typestring = get_string('daily', 'questionnaire');
- break;
- case QUESTIONNAIREWEEKLY:
- $typestring = get_string('weekly', 'questionnaire');
- break;
- case QUESTIONNAIREMONTHLY:
- $typestring = get_string('monthly', 'questionnaire');
- break;
- default:
- $typestring = '';
- break;
- }
- array_push($messages, get_string('attemptsallowed', 'questionnaire', $typestring));
- }
-
- if ($this->is_open() && !$this->is_closed()) {
- if ($this->opendate > 0) {
- array_push($messages, get_string('openedat', 'questionnaire', userdate($this->opendate)));
- }
- if ($this->closedate > 0) {
- array_push($messages, get_string('closesat', 'questionnaire', userdate($this->closedate)));
- }
- }
-
- return $messages;
- }
-
- /**
- * Print each message in an array, surrounded by <p>, </p> tags.
- *
- * @param array $messages the array of message strings.
- * @return string HTML to output.
- */
- public function access_messages($messages) {
- $output = '';
- foreach ($messages as $message) {
- $output .= html_writer::tag('p', $message) . "\n";
- }
- return $output;
- }
-}
diff --git a/questions.php b/questions.php
index f1775f700..0f359e3b6 100644
--- a/questions.php
+++ b/questions.php
@@ -23,33 +23,30 @@
*/
require_once("../../config.php");
-require_once($CFG->dirroot . '/mod/questionnaire/questionnaire.class.php');
-require_once($CFG->dirroot . '/mod/questionnaire/classes/question/question.php'); // Needed for question type constants.
+require_once($CFG->dirroot . '/mod/questionnaire/classes/local/question/question.php'); // Needed for question type constants.
+
+use mod_questionnaire\questionnaire;
+use mod_questionnaire\local\question_type;
+use mod_questionnaire\local\response\questionnaire_responses;
+use mod_questionnaire\output\questionspage;
+use mod_questionnaire\local\survey\survey;
$id = required_param('id', PARAM_INT); // Course module ID.
$action = optional_param('action', 'main', PARAM_ALPHA); // Screen.
$qid = optional_param('qid', 0, PARAM_INT); // Question id.
$moveq = optional_param('moveq', 0, PARAM_INT); // Question id to move.
$delq = optional_param('delq', 0, PARAM_INT); // Question id to delete.
-$qtype = optional_param('type_id', 0, PARAM_INT); // Question type.
+$qtype = optional_param('typeid', 0, PARAM_INT); // Question type.
$currentgroupid = optional_param('group', 0, PARAM_INT); // Group id.
$delpermanentlyq = optional_param('delpermanentlyq', 0, PARAM_INT); // Question id to delete.
-$restoreq = optional_param(QUESTIONNAIRE_RESTORE_PARAM, 0, PARAM_INT); // Question id to restore question.
-
-if (! $cm = get_coursemodule_from_id('questionnaire', $id)) {
- throw new \moodle_exception('invalidcoursemodule', 'mod_questionnaire');
-}
-
-if (! $course = $DB->get_record("course", ["id" => $cm->course])) {
- throw new \moodle_exception('coursemisconf', 'mod_questionnaire');
-}
+$restoreq = optional_param(questionnaire::restore_param(), 0, PARAM_INT); // Question id to restore question.
-if (! $questionnaire = $DB->get_record("questionnaire", ["id" => $cm->instance])) {
- throw new \moodle_exception('invalidcoursemodule', 'mod_questionnaire');
-}
+$questionnaire = questionnaire::from_cmid($id);
+$course = $questionnaire->course();
+$cm = $questionnaire->coursemodule();
require_course_login($course, true, $cm);
-$context = context_module::instance($cm->id);
+$context = $questionnaire->context();
$url = new moodle_url($CFG->wwwroot . '/mod/questionnaire/questions.php');
$url->param('id', $id);
@@ -60,41 +57,36 @@
$PAGE->set_url($url);
$PAGE->set_context($context);
-$questionnaire = new questionnaire($course, $cm, 0, $questionnaire);
-$questionnaire->get_delete_questions();
+$deletequestions = $questionnaire->survey()->get_delete_questions();
+$questions = $questionnaire->questions();
-// Add renderer and page objects to the questionnaire object for display use.
-$questionnaire->add_renderer($PAGE->get_renderer('mod_questionnaire'));
-$questionnaire->add_page(new \mod_questionnaire\output\questionspage());
+$renderer = $PAGE->get_renderer('mod_questionnaire');
+$page = new questionspage();
-if (!$questionnaire->capabilities->editquestions) {
+if (!$questionnaire->capabilities()->can_edit_questions()) {
throw new \moodle_exception('nopermissions', 'mod_questionnaire');
}
-$questionnairehasdependencies = $questionnaire->has_dependencies();
+$questionnairehasdependencies = $questionnaire->navigator()->has_dependencies();
$dependants = null;
-if (!isset($SESSION->questionnaire)) {
- $SESSION->questionnaire = new stdClass();
-}
-$SESSION->questionnaire->current_tab = 'questions';
$reload = false;
-$sid = $questionnaire->survey->id;
+$sid = $questionnaire->surveyid();
// Process form data.
// Delete question button has been pressed in questions_form AND deletion has been confirmed on the confirmation page.
if ($delq) {
$qid = $delq;
- $sid = $questionnaire->survey->id;
- $questionnaireid = $questionnaire->id;
+ $sid = $questionnaire->surveyid();
+ $questionnaireid = $questionnaire->id();
// Need to reload questions before setting deleted question to 'y'.
- $questions = $DB->get_records_select(
+ $dbquestions = $DB->get_records_select(
'questionnaire_question',
'surveyid = :sid AND deleted IS NULL',
['sid' => $sid],
'id'
);
- if (isset($questions[$qid]) && $questions[$qid]->type_id == QUESPAGEBREAK) {
+ if (isset($dbquestions[$qid]) && $dbquestions[$qid]->typeid == QUESPAGEBREAK) {
$DB->delete_records('questionnaire_question', ['id' => $qid]);
} else {
$updatesql = "UPDATE {questionnaire_question}
@@ -105,15 +97,16 @@
}
// Delete all dependency records for this question.
- questionnaire_delete_dependencies($qid);
+ $DB->delete_records('questionnaire_dependency', ['questionid' => $qid]);
+ $DB->delete_records('questionnaire_dependency', ['dependquestionid' => $qid]);
// Delete all page break that references to question deleted.
- questionnaire_delete_pagebreaks($sid);
+ survey::delete_pagebreaks($sid);
// Just in case the page is refreshed (F5) after a question has been deleted.
- if (isset($questions[$qid])) {
- $select = 'surveyid = ' . $sid . ' AND deleted IS NULL AND position > ' . $questions[$qid]->position;
+ if (isset($dbquestions[$qid])) {
+ $select = 'surveyid = ' . $sid . ' AND deleted IS NULL AND position > ' . $dbquestions[$qid]->position;
} else {
- redirect($CFG->wwwroot . '/mod/questionnaire/questions.php?id=' . $questionnaire->cm->id);
+ redirect($CFG->wwwroot . '/mod/questionnaire/questions.php?id=' . $cm->id);
}
if ($records = $DB->get_records_select('questionnaire_question', $select, null, 'position ASC')) {
@@ -123,11 +116,11 @@
}
// Delete section breaks without asking for confirmation.
// No need to delete responses to those "question types" which are not real questions.
- if (!$questionnaire->questions[$qid]->supports_responses()) {
+ if (!$questions[$qid]->supports_responses()) {
$reload = true;
} else {
// Delete responses to that deleted question.
- questionnaire_delete_responses($qid);
+ $questionnaire->responses()->delete_responses($qid);
// If no questions left in this questionnaire, remove all responses.
if (
@@ -142,11 +135,14 @@
}
// Log question deleted event.
- $questiontype = \mod_questionnaire\question\question::qtypename($questionnaire->questions[$qid]->type_id);
- questionnaire_observe_event_delete($questionnaire->cm->id, $questiontype, $questionnaire->course->id);
+ $questiontype = \mod_questionnaire\local\question\question::qtypename($questions[$qid]->typeid());
+ survey::trigger_question_deleted_event($cm->id, $questiontype, $questionnaire->courseid());
if ($questionnairehasdependencies) {
- $SESSION->questionnaire->validateresults = questionnaire_check_page_breaks($questionnaire);
+ $validationmsg = $questionnaire->survey()->check_page_breaks();
+ if (!empty($validationmsg)) {
+ \core\notification::warning($validationmsg);
+ }
}
$reload = true;
}
@@ -154,13 +150,13 @@
// Delete question permanently.
if ($delpermanentlyq) {
$qid = $delpermanentlyq;
- $sid = $questionnaire->survey->id;
- questionnaire_delete_permanently_questions($qid, $sid);
- $deletedquestion = $questionnaire->deletequestions[$qid] ?? null;
+ $sid = $questionnaire->surveyid();
+ survey::delete_question_permanently($qid, $sid);
+ $deletedquestion = $deletequestions[$qid] ?? null;
if ($deletedquestion !== null) {
- $questiontype = \mod_questionnaire\question\question::qtypename($deletedquestion->type_id);
- questionnaire_observe_event_delete($questionnaire->cm->id, $questiontype, $questionnaire->course->id);
- $url = new moodle_url('/mod/questionnaire/questions.php', ['id' => $questionnaire->cm->id]);
+ $questiontype = \mod_questionnaire\local\question\question::qtypename($deletedquestion->typeid());
+ survey::trigger_question_deleted_event($cm->id, $questiontype, $questionnaire->courseid());
+ $url = new moodle_url('/mod/questionnaire/questions.php', ['id' => $cm->id]);
$PAGE->set_url($url->out(false));
$reload = true;
}
@@ -169,23 +165,23 @@
// Restore question.
if ($restoreq) {
$qid = $restoreq;
- $qdeleted = isset($questionnaire->deletequestions[$qid]) ? $questionnaire->deletequestions[$qid] : false;
+ $qdeleted = $deletequestions[$qid] ?? false;
if ($qid && $qdeleted) {
- questionnaire_restore_deleted_question($qid, $qdeleted->surveyid);
+ survey::restore_deleted_question($qid, $qdeleted->surveyid());
}
- $url = new moodle_url('/mod/questionnaire/questions.php', ['id' => $questionnaire->cm->id]);
+ $url = new moodle_url('/mod/questionnaire/questions.php', ['id' => $cm->id]);
$PAGE->set_url($url->out(false));
$reload = true;
}
if ($action == 'main') {
- $questionsform = new \mod_questionnaire\questions_form('questions.php', $moveq);
- $sdata = clone($questionnaire->survey);
- $sdata->sid = $questionnaire->survey->id;
+ $questionsform = new \mod_questionnaire\questions_form('questions.php', $moveq, $renderer);
+ $sdata = $questionnaire->survey()->to_stdclass();
+ $sdata->sid = $questionnaire->surveyid();
$sdata->id = $cm->id;
- if (!empty($questionnaire->questions)) {
+ if (!empty($questions)) {
$pos = 1;
- foreach ($questionnaire->questions as $qidx => $question) {
+ foreach ($questions as $qidx => $question) {
$sdata->{'pos_' . $qidx} = $pos;
$pos++;
}
@@ -194,7 +190,7 @@
if ($questionsform->is_cancelled()) {
// Switch to main screen.
$action = 'main';
- redirect($CFG->wwwroot . '/mod/questionnaire/questions.php?id=' . $questionnaire->cm->id);
+ redirect($CFG->wwwroot . '/mod/questionnaire/questions.php?id=' . $cm->id);
$reload = true;
}
if ($qformdata = $questionsform->get_data()) {
@@ -223,18 +219,18 @@
// Need to use the key, since IE returns the image position as the value rather than the specified
// value in the tag.
$qid = key($qformdata->removebutton);
- $qtype = $questionnaire->questions[$qid]->type_id;
+ $qtype = $questions[$qid]->typeid();
// Delete section breaks without asking for confirmation.
if ($qtype == QUESPAGEBREAK) {
- redirect(new \moodle_url('/mod/questionnaire/questions.php', ['id' => $questionnaire->cm->id, 'delq' => $qid]));
+ redirect(new \moodle_url('/mod/questionnaire/questions.php', ['id' => $cm->id, 'delq' => $qid]));
}
$action = "confirmdelquestion";
if ($questionnairehasdependencies) {
// Important: due to possibly multiple parents per question
// just remove the dependency and inform the user about it.
- $dependants = $questionnaire->get_all_dependants($qid);
+ $dependants = $questionnaire->navigator()->get_all_dependants($qid);
if (!(empty($dependants->directs) && empty($dependants->indirects))) {
$action = "confirmdelquestionparent";
}
@@ -251,32 +247,32 @@
// value in the tag.
$qid = key($qformdata->requiredbutton);
- if ($questionnaire->questions[$qid]->required()) {
- $questionnaire->questions[$qid]->set_required(false);
+ if ($questions[$qid]->required()) {
+ $questions[$qid]->set_required(false);
} else {
- $questionnaire->questions[$qid]->set_required(true);
+ $questions[$qid]->set_required(true);
}
$reload = true;
} else if (isset($qformdata->addqbutton)) {
- if ($qformdata->type_id == QUESPAGEBREAK) { // Adding section break is handled right away....
+ if ($qformdata->typeid == QUESPAGEBREAK) { // Adding section break is handled right away....
$questionrec = new stdClass();
$questionrec->surveyid = $qformdata->sid;
- $questionrec->type_id = QUESPAGEBREAK;
+ $questionrec->typeid = QUESPAGEBREAK;
$questionrec->content = 'break';
- $question = \mod_questionnaire\question\question::question_builder(QUESPAGEBREAK);
+ $question = \mod_questionnaire\local\question\question::question_builder(QUESPAGEBREAK);
$question->add($questionrec);
$reload = true;
} else {
// Switch to edit question screen.
$action = 'question';
- $qtype = $qformdata->type_id;
+ $qtype = $qformdata->typeid;
$qid = 0;
$reload = true;
}
} else if (isset($qformdata->movebutton)) {
// Nothing I do will seem to reload the form with new data, except for moving away from the page, so...
- redirect($CFG->wwwroot . '/mod/questionnaire/questions.php?id=' . $questionnaire->cm->id .
+ redirect($CFG->wwwroot . '/mod/questionnaire/questions.php?id=' . $cm->id .
'&moveq=' . key($qformdata->movebutton));
$reload = true;
} else if (isset($qformdata->moveherebutton)) {
@@ -286,34 +282,42 @@
// No need to move question if new position = old position!
$qpos = key($qformdata->moveherebutton);
if ($qformdata->moveq != $qpos) {
- $questionnaire->move_question($qformdata->moveq, $qpos);
+ $questionnaire->survey()->move_question($qformdata->moveq, $qpos);
}
if ($questionnairehasdependencies) {
- $SESSION->questionnaire->validateresults = questionnaire_check_page_breaks($questionnaire);
+ $validationmsg = $questionnaire->survey()->check_page_breaks();
+ if (!empty($validationmsg)) {
+ \core\notification::warning($validationmsg);
+ }
}
// Nothing I do will seem to reload the form with new data, except for moving away from the page, so...
- redirect($CFG->wwwroot . '/mod/questionnaire/questions.php?id=' . $questionnaire->cm->id);
+ redirect($CFG->wwwroot . '/mod/questionnaire/questions.php?id=' . $cm->id);
$reload = true;
} else if (isset($qformdata->validate)) {
// Validates page breaks for depend questions.
- $SESSION->questionnaire->validateresults = questionnaire_check_page_breaks($questionnaire);
+ $validationmsg = $questionnaire->survey()->check_page_breaks();
+ if (!empty($validationmsg)) {
+ \core\notification::warning($validationmsg);
+ }
$reload = true;
} else if (isset($qformdata->deletebutton)) {
- $action = QUESTIONNAIRE_CONFIRM_DELETE_PERMANENTLY;
+ $action = questionnaire::confirm_delete_param();
} else if (isset($qformdata->restorebutton)) {
$qid = key($qformdata->restorebutton);
redirect(
new moodle_url(
'/mod/questionnaire/questions.php',
- ['id' => $questionnaire->cm->id, QUESTIONNAIRE_RESTORE_PARAM => $qid]
+ ['id' => $cm->id, questionnaire::restore_param() => $qid]
)
);
}
}
} else if ($action == 'question') {
- $question = questionnaire_prep_for_questionform($questionnaire, $qid, $qtype);
+ [$question, $editorcontent] = $questionnaire->survey()->prep_question_for_form($qid, $qtype);
$questionsform = new \mod_questionnaire\edit_question_form('questions.php');
- $questionsform->set_data($question);
+ $formdata = $question->form_data();
+ $formdata->content = $editorcontent;
+ $questionsform->set_data($formdata);
if ($questionsform->is_cancelled()) {
// Switch to main screen.
$action = 'main';
@@ -331,64 +335,66 @@
$qformdata->required = 'n';
}
- questionnaire_check_page_breaks($questionnaire);
- $SESSION->questionnaire->required = $qformdata->required;
- $SESSION->questionnaire->type_id = $qformdata->type_id;
- // Switch to main screen.
- $action = 'main';
- $reload = true;
- }
+ $questionnaire->survey()->check_page_breaks();
- // Log question created event.
- if (isset($qformdata)) {
- $context = context_module::instance($questionnaire->cm->id);
- $questiontype = \mod_questionnaire\question\question::qtypename($qformdata->type_id);
+ // Log question created event.
+ $questiontype = \mod_questionnaire\local\question\question::qtypename($qformdata->typeid);
$params = [
'context' => $context,
- 'courseid' => $questionnaire->course->id,
+ 'courseid' => $questionnaire->courseid(),
'other' => ['questiontype' => $questiontype],
];
- $event = \mod_questionnaire\event\question_created::create($params);
- $event->trigger();
+ \mod_questionnaire\event\question_created::create($params)->trigger();
+
+ // Back to the main screen; carry the just-used typeid/required forward as defaults for the next add.
+ redirect(new moodle_url('/mod/questionnaire/questions.php', [
+ 'id' => $cm->id,
+ 'lasttypeid' => (int) $qformdata->typeid,
+ 'lastrequired' => $qformdata->required,
+ ]));
}
- $questionsform->set_data($question);
+ $formdata = $question->form_data();
+ $formdata->content = $editorcontent;
+ $questionsform->set_data($formdata);
}
// Reload the form data if called for...
if ($reload) {
unset($questionsform);
- $questionnaire = new questionnaire($course, $cm, $questionnaire->id, null);
- $questionnaire->get_delete_questions();
- // Add renderer and page objects to the questionnaire object for display use.
- $questionnaire->add_renderer($PAGE->get_renderer('mod_questionnaire'));
- $questionnaire->add_page(new \mod_questionnaire\output\questionspage());
+ $questionnaire = questionnaire::from_cmid($id);
+ $deletequestions = $questionnaire->survey()->get_delete_questions();
+ $questions = $questionnaire->questions();
+ $renderer = $PAGE->get_renderer('mod_questionnaire');
+ $page = new questionspage();
if ($action == 'main') {
- $questionsform = new \mod_questionnaire\questions_form('questions.php', $moveq);
- $sdata = clone($questionnaire->survey);
- $sdata->sid = $questionnaire->survey->id;
+ $questionsform = new \mod_questionnaire\questions_form('questions.php', $moveq, $renderer);
+ $sdata = $questionnaire->survey()->to_stdclass();
+ $sdata->sid = $questionnaire->surveyid();
$sdata->id = $cm->id;
- if (!empty($questionnaire->questions)) {
+ if (!empty($questions)) {
$pos = 1;
- foreach ($questionnaire->questions as $qidx => $question) {
+ foreach ($questions as $qidx => $question) {
$sdata->{'pos_' . $qidx} = $pos;
$pos++;
}
}
$questionsform->set_data($sdata);
} else if ($action == 'question') {
- $question = questionnaire_prep_for_questionform($questionnaire, $qid, $qtype);
+ [$question, $editorcontent] = $questionnaire->survey()->prep_question_for_form($qid, $qtype);
$questionsform = new \mod_questionnaire\edit_question_form('questions.php');
- $questionsform->set_data($question);
+ $formdata = $question->form_data();
+ $formdata->content = $editorcontent;
+ $questionsform->set_data($formdata);
}
}
// Print the page header.
if ($action == 'question') {
if (isset($question->qid)) {
- $streditquestion = get_string('editquestion', 'questionnaire', questionnaire_get_type($question->type_id));
+ $streditquestion = get_string('editquestion', 'questionnaire', question_type::display_name($question->typeid()));
} else {
- $streditquestion = get_string('addnewquestion', 'questionnaire', questionnaire_get_type($question->type_id));
+ $streditquestion = get_string('addnewquestion', 'questionnaire', question_type::display_name($question->typeid()));
}
} else {
$streditquestion = get_string('managequestions', 'questionnaire');
@@ -397,39 +403,39 @@
$PAGE->set_title($streditquestion);
$PAGE->set_heading(format_string($course->fullname));
$PAGE->navbar->add($streditquestion);
-echo $questionnaire->renderer->header();
-require('tabs.php');
+echo $renderer->header();
+(new \mod_questionnaire\output\tabs($questionnaire, 'questions'))->render($page);
if ($action == "confirmdelquestion" || $action == "confirmdelquestionparent") {
$qid = key($qformdata->removebutton);
- $question = $questionnaire->questions[$qid];
- $qtype = $question->type_id;
+ $question = $questions[$qid];
+ $qtype = $question->typeid();
- $countresps = count_reponses_question($qid, $qtype);
+ $countresps = questionnaire_responses::count_for_question($qid, $qtype);
// Needed to print potential media in question text.
// 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 = '';
}
$qname = '';
- if ($question->name) {
- $qname = ' (' . $question->name . ')';
+ if ($question->name()) {
+ $qname = ' (' . $question->name() . ')';
}
$num = get_string('position', 'questionnaire');
- $pos = $question->position . $qname;
+ $pos = $question->position() . $qname;
$msg = '' . get_string('confirmdelquestion', 'questionnaire', $pos) . '
';
if ($countresps !== 0) {
$msg .= '
' . get_string('confirmdelquestionresps', 'questionnaire', $countresps) . '
';
}
$msg .= '
';
- $msg .= '' . $num . ' ' . $pos . '
' . $question->content . '
';
- $args = "id={$questionnaire->cm->id}";
+ $msg .= '' . $num . ' ' . $pos . '
' . $displaycontent . '
';
+ $args = "id={$cm->id}";
$urlno = new moodle_url("/mod/questionnaire/questions.php?{$args}");
$args .= "&delq={$qid}";
$urlyes = new moodle_url("/mod/questionnaire/questions.php?{$args}");
@@ -442,20 +448,20 @@
// Show the dependencies and inform about the dependencies to be removed.
// Split dependencies in direct and indirect ones to separate for the confirm-dialogue.
// Only direct ones will be deleted. List direct dependencies.
- $msg .= $questionnaire->renderer->dependency_warnings($dependants->directs, 'directwarnings', $strnum);
+ $msg .= $renderer->dependency_warnings($dependants->directs, 'directwarnings', $strnum);
// List indirect dependencies.
- $msg .= $questionnaire->renderer->dependency_warnings($dependants->indirects, 'indirectwarnings', $strnum);
+ $msg .= $renderer->dependency_warnings($dependants->indirects, 'indirectwarnings', $strnum);
}
}
- $questionnaire->page->add_to_page('formarea', $questionnaire->renderer->confirm($msg, $buttonyes, $buttonno));
-} else if ($action === QUESTIONNAIRE_CONFIRM_DELETE_PERMANENTLY) {
+ $page->add_to_page('formarea', $renderer->confirm($msg, $buttonyes, $buttonno));
+} else if ($action === questionnaire::confirm_delete_param()) {
$qid = key($qformdata->deletebutton);
- $qtype = $questionnaire->deletequestions[$qid]->type_id;
- $questiondelete = $questionnaire->deletequestions[$qid];
- $countresps = count_reponses_question($qid, $qtype);
+ $qtype = $deletequestions[$qid]->typeid();
+ $questiondelete = $deletequestions[$qid];
+ $countresps = questionnaire_responses::count_for_question($qid, $qtype);
- $urlno = new moodle_url("/mod/questionnaire/questions.php", ['id' => $questionnaire->cm->id]);
- $urlyes = new moodle_url("/mod/questionnaire/questions.php", ['id' => $questionnaire->cm->id, "delpermanentlyq" => $qid]);
+ $urlno = new moodle_url("/mod/questionnaire/questions.php", ['id' => $cm->id]);
+ $urlyes = new moodle_url("/mod/questionnaire/questions.php", ['id' => $cm->id, "delpermanentlyq" => $qid]);
$buttonyes = new single_button($urlyes, get_string('yes'));
$buttonno = new single_button($urlno, get_string('no'));
$msg = '' . get_string('confirmdelpermanentlyq', 'questionnaire') . '
';
@@ -463,12 +469,12 @@
$msg .= '
' . get_string('confirmdelquestionresps', 'questionnaire', $countresps) . '
';
}
$msg .= '
';
- $msg .= 'NA (' . $questiondelete->name . ')
-
' . $questiondelete->content . '
';
+ $msg .= 'NA (' . $questiondelete->name() . ')
+
' . $questiondelete->content() . '
';
- $questionnaire->page->add_to_page('formarea', $questionnaire->renderer->confirm($msg, $buttonyes, $buttonno));
+ $page->add_to_page('formarea', $renderer->confirm($msg, $buttonyes, $buttonno));
} else {
- $questionnaire->page->add_to_page('formarea', $questionsform->render());
+ $page->add_to_page('formarea', $questionsform->render());
}
-echo $questionnaire->renderer->render($questionnaire->page);
-echo $questionnaire->renderer->footer();
+echo $renderer->render($page);
+echo $renderer->footer();
diff --git a/report.php b/report.php
index 71567bca1..b37af627d 100755
--- a/report.php
+++ b/report.php
@@ -25,9 +25,16 @@
*/
require_once("../../config.php");
-require_once($CFG->dirroot . '/mod/questionnaire/questionnaire.class.php');
-$instance = optional_param('instance', false, PARAM_INT); // Questionnaire ID.
+use mod_questionnaire\questionnaire;
+use mod_questionnaire\local\report\report_actions;
+use mod_questionnaire\local\report\report_downloader;
+use mod_questionnaire\local\report\report_viewer;
+use mod_questionnaire\output\reportpage;
+use mod_questionnaire\output\reportpagepdf;
+use mod_questionnaire\output\responsepagepdf;
+
+$instance = required_param('instance', PARAM_INT); // Questionnaire ID.
$action = optional_param('action', 'vall', PARAM_ALPHA);
$sid = optional_param('sid', null, PARAM_INT); // Survey id.
$rid = optional_param('rid', false, PARAM_INT);
@@ -51,53 +58,29 @@
$sort = 'default';
}
-if ($instance === false) {
- if (!empty($SESSION->instance)) {
- $instance = $SESSION->instance;
- } else {
- throw new \moodle_exception('requiredparameter', 'mod_questionnaire');
- }
-}
-$SESSION->instance = $instance;
-$usergraph = get_config('questionnaire', 'usergraph');
-
-if (! $questionnaire = $DB->get_record("questionnaire", ["id" => $instance])) {
- throw new \moodle_exception('incorrectquestionnaire', 'mod_questionnaire');
-}
-if (! $course = $DB->get_record("course", ["id" => $questionnaire->course])) {
- throw new \moodle_exception('coursemisconf', 'mod_questionnaire');
-}
-if (! $cm = get_coursemodule_from_instance("questionnaire", $questionnaire->id, $course->id)) {
- throw new \moodle_exception('invalidcoursemodule', 'mod_questionnaire');
-}
+$questionnaire = questionnaire::from_instanceid($instance);
+$course = $questionnaire->course();
+$cm = $questionnaire->coursemodule();
require_course_login($course, true, $cm);
-$questionnaire = new questionnaire($course, $cm, 0, $questionnaire);
-
-// Add renderer and page objects to the questionnaire object for display use.
-$questionnaire->add_renderer($PAGE->get_renderer('mod_questionnaire'));
+$renderer = $PAGE->get_renderer('mod_questionnaire');
if ($outputtarget == 'pdf') {
- if ($action == 'vresp') {
- $questionnaire->add_page(new \mod_questionnaire\output\responsepagepdf());
- } else {
- $questionnaire->add_page(new \mod_questionnaire\output\reportpagepdf());
- }
+ $page = ($action == 'vresp') ? new responsepagepdf() : new reportpagepdf();
} else { // Default to HTML.
- $questionnaire->add_page(new \mod_questionnaire\output\reportpage());
+ $page = new reportpage();
}
// If you can't view the questionnaire, or can't view a specified response, error out.
-$context = context_module::instance($cm->id);
+$context = $questionnaire->context();
if (
- !$questionnaire->can_view_all_responses(null, true) &&
- !($individualresponse && $questionnaire->can_view_response($rid))
+ !$questionnaire->capabilities()->can_view_all_responses(null, true) &&
+ !($individualresponse && $questionnaire->capabilities()->can_view_response($rid))
) {
throw new \moodle_exception('nopermissions', 'mod_questionnaire');
}
-$questionnaire->canviewallgroups = has_capability('moodle/site:accessallgroups', $context);
-$sid = $questionnaire->survey->id;
+$sid = $questionnaire->surveyid();
$url = new moodle_url($CFG->wwwroot . '/mod/questionnaire/report.php');
if ($instance) {
@@ -129,33 +112,23 @@
$PAGE->set_context($context);
if ($outputtarget == 'print') {
$PAGE->set_pagelayout('popup');
- $PAGE->requires->js_init_call('M.mod_questionnaire.init_printing');
+ $PAGE->requires->js_call_amd('mod_questionnaire/printing', 'init');
}
-// Tab setup.
-if (!isset($SESSION->questionnaire)) {
- $SESSION->questionnaire = new stdClass();
-}
-$SESSION->questionnaire->current_tab = 'allreport';
-
// Get all responses for further use in viewbyresp and deleteall etc.
// All participants.
$respsallparticipants = $questionnaire->get_responses();
-$SESSION->questionnaire->numrespsallparticipants = count($respsallparticipants);
-$SESSION->questionnaire->numselectedresps = $SESSION->questionnaire->numrespsallparticipants;
// Available group modes (0 = no groups; 1 = separate groups; 2 = visible groups).
$groupmode = groups_get_activity_groupmode($cm, $course);
$questionnairegroups = '';
$groupscount = 0;
-$SESSION->questionnaire->respscount = 0;
-$SESSION->questionnaire_surveyid = $sid;
if ($groupmode > 0) {
if ($groupmode == 1) {
$questionnairegroups = groups_get_all_groups($course->id, $userid);
}
- if ($groupmode == 2 || $questionnaire->canviewallgroups) {
+ if ($groupmode == 2 || $questionnaire->capabilities()->can_view_all_groups()) {
$questionnairegroups = groups_get_all_groups($course->id);
}
@@ -168,13 +141,13 @@
if ($groupscount === 0 && $groupmode == 1) {
$currentgroupid = 0;
}
- if ($groupmode == 1 && !$questionnaire->canviewallgroups && $currentgroupid == 0) {
+ if ($groupmode == 1 && !$questionnaire->capabilities()->can_view_all_groups() && $currentgroupid == 0) {
$currentgroupid = $firstgroupid;
}
} else {
// Groupmode = separate groups but user is not member of any group
// and does not have moodle/site:accessallgroups capability -> refuse view responses.
- if (!$questionnaire->canviewallgroups) {
+ if (!$questionnaire->capabilities()->can_view_all_groups()) {
$currentgroupid = 0;
}
}
@@ -185,31 +158,6 @@
$groupname = '' . get_string('allparticipants') . ' ';
}
}
-if ($usergraph) {
- $charttype = $questionnaire->survey->chart_type;
- if ($charttype) {
- $PAGE->requires->js('/mod/questionnaire/javascript/RGraph/RGraph.common.core.js');
-
- switch ($charttype) {
- case 'bipolar':
- $PAGE->requires->js('/mod/questionnaire/javascript/RGraph/RGraph.bipolar.js');
- break;
- case 'hbar':
- $PAGE->requires->js('/mod/questionnaire/javascript/RGraph/RGraph.hbar.js');
- break;
- case 'radar':
- $PAGE->requires->js('/mod/questionnaire/javascript/RGraph/RGraph.radar.js');
- break;
- case 'rose':
- $PAGE->requires->js('/mod/questionnaire/javascript/RGraph/RGraph.rose.js');
- break;
- case 'vprogress':
- $PAGE->requires->js('/mod/questionnaire/javascript/RGraph/RGraph.vprogress.js');
- break;
- }
- }
-}
-
$responsestatus = [
'y' => get_string('fullsubmissions', 'questionnaire'),
'0' => get_string('allresponses', 'questionnaire'),
@@ -220,735 +168,66 @@
switch ($action) {
case 'dresp': // Delete individual response? Ask for confirmation.
- require_capability('mod/questionnaire:deleteresponses', $context);
-
- if (empty($questionnaire->survey)) {
- throw new \moodle_exception('surveynotexists', 'mod_questionnaire');
- } else if ($questionnaire->survey->courseid != $course->id) {
- throw new \moodle_exception('surveyowner', 'mod_questionnaire');
- } else if (!$rid || !is_numeric($rid)) {
- throw new \moodle_exception('invalidresponse', 'mod_questionnaire');
- } else if (!($resp = $DB->get_record('questionnaire_response', ['id' => $rid]))) {
- throw new \moodle_exception('invalidresponserecord', 'mod_questionnaire');
- }
-
- $ruser = false;
- if (!empty($resp->userid)) {
- if ($user = $DB->get_record('user', ['id' => $resp->userid])) {
- $ruser = fullname($user);
- } else {
- $ruser = '- ' . get_string('unknown', 'questionnaire') . ' -';
- }
- } else {
- $ruser = $resp->userid;
- }
-
- // Print the page header.
- $PAGE->set_title(get_string('deletingresp', 'questionnaire'));
- $PAGE->set_heading(format_string($course->fullname));
- echo $questionnaire->renderer->header();
-
- // Print the tabs.
- $SESSION->questionnaire->current_tab = 'deleteresp';
- include('tabs.php');
-
- $timesubmitted = ' ' . get_string('submitted', 'questionnaire') . ' ' . userdate($resp->submitted);
- if ($questionnaire->respondenttype == 'anonymous') {
- $ruser = '- ' . get_string('anonymous', 'questionnaire') . ' -';
- $timesubmitted = '';
- }
-
- // Print the confirmation.
- $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');
- $questionnaire->page->add_to_page('notifications', $questionnaire->renderer->confirm($msg, $buttonyes, $buttonno));
- echo $questionnaire->renderer->render($questionnaire->page);
- // Finish the page.
- echo $questionnaire->renderer->footer($course);
+ (new report_actions($questionnaire, $renderer))
+ ->confirm_delete_response($page, (int)$rid, $currentgroupid);
break;
case 'delallresp': // Delete all responses? Ask for confirmation.
- require_capability('mod/questionnaire:deleteresponses', $context);
-
- if (!empty($respsallparticipants)) {
- // Print the page header.
- $PAGE->set_title(get_string('deletingresp', 'questionnaire'));
- $PAGE->set_heading(format_string($course->fullname));
- echo $questionnaire->renderer->header();
-
- // Print the tabs.
- $SESSION->questionnaire->current_tab = 'deleteall';
- include('tabs.php');
-
- // Print the confirmation.
- $msg = '';
- if ($groupmode == 0) { // No groups or visible groups.
- $msg .= get_string('confirmdelallresp', 'questionnaire');
- } else { // Separate groups.
- $msg .= get_string('confirmdelgroupresp', 'questionnaire', $groupname);
- }
- $msg .= '
';
-
- $urlyes = new moodle_url('report.php', [
- 'action' => 'dvallresp',
- 'sid' => $sid,
- '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');
-
- $questionnaire->page->add_to_page('notifications', $questionnaire->renderer->confirm($msg, $buttonyes, $buttonno));
- echo $questionnaire->renderer->render($questionnaire->page);
- // Finish the page.
- echo $questionnaire->renderer->footer($course);
- }
+ (new report_actions($questionnaire, $renderer))
+ ->confirm_delete_all_responses($page, $groupmode, $currentgroupid, $groupname ?? '', $respsallparticipants);
break;
case 'dvresp': // Delete single response. Do it!
- require_capability('mod/questionnaire:deleteresponses', $context);
-
- if (empty($questionnaire->survey)) {
- throw new \moodle_exception('surveynotexists', 'mod_questionnaire');
- } else if ($questionnaire->survey->courseid != $course->id) {
- throw new \moodle_exception('surveyowner', 'mod_questionnaire');
- } else if (!$rid || !is_numeric($rid)) {
- throw new \moodle_exception('invalidresponse', 'mod_questionnaire');
- } else if (!($response = $DB->get_record('questionnaire_response', ['id' => $rid]))) {
- throw new \moodle_exception('invalidresponserecord', 'mod_questionnaire');
- }
-
- if (questionnaire_delete_response($response, $questionnaire)) {
- if (!$DB->count_records('questionnaire_response', ['questionnaireid' => $questionnaire->id, 'complete' => 'y'])) {
- $redirection = $CFG->wwwroot . '/mod/questionnaire/view.php?id=' . $cm->id;
- } else {
- $redirection = $CFG->wwwroot . '/mod/questionnaire/report.php?action=vresp&instance=' .
- $instance . '&byresponse=1';
- }
-
- // Log this questionnaire delete single response action.
- $params = [
- 'objectid' => $questionnaire->survey->id,
- 'context' => $questionnaire->context,
- 'courseid' => $questionnaire->course->id,
- 'relateduserid' => $response->userid,
- ];
- $event = \mod_questionnaire\event\response_deleted::create($params);
- $event->trigger();
-
- redirect($redirection);
- } else {
- if ($questionnaire->respondenttype == 'anonymous') {
- $ruser = '- ' . get_string('anonymous', 'questionnaire') . ' -';
- } else if (!empty($response->userid)) {
- if ($user = $DB->get_record('user', ['id' => $response->userid])) {
- $ruser = fullname($user);
- } else {
- $ruser = '- ' . get_string('unknown', 'questionnaire') . ' -';
- }
- } else {
- $ruser = $response->userid;
- }
- $link = new \moodle_url('/mod/questionnaire/report.php', [
- 'action' => 'vresp',
- 'sid' => $sid,
- 'instance' => $instance,
- 'byresponse' => '1',
- ]);
- throw new \moodle_exception(
- 'couldnotdelrespby',
- 'mod_questionnaire',
- $link,
- ['rid' => $rid, 'user' => $ruser]
- );
- }
+ (new report_actions($questionnaire, $renderer))->delete_response((int)$rid);
break;
case 'dvallresp': // Delete all responses in questionnaire (or group). Do it!
- require_capability('mod/questionnaire:deleteresponses', $context);
-
- if (empty($questionnaire->survey)) {
- throw new \moodle_exception('surveynotexists', 'mod_questionnaire');
- } else if ($questionnaire->survey->courseid != $course->id) {
- throw new \moodle_exception('surveyowner', 'mod_questionnaire');
- }
-
- // Available group modes (0 = no groups; 1 = separate groups; 2 = visible groups).
- if ($groupmode > 0) {
- switch ($currentgroupid) {
- case 0: // All participants.
- $resps = $respsallparticipants;
- break;
- default: // Members of a specific group.
- if (!($resps = $questionnaire->get_responses(false, $currentgroupid))) {
- $resps = [];
- }
- }
- if (empty($resps)) {
- $noresponses = true;
- } else {
- if ($rid === false) {
- $resp = current($resps);
- $rid = $resp->id;
- } else {
- $resp = $DB->get_record('questionnaire_response', ['id' => $rid]);
- }
- if (!empty($resp->userid)) {
- if ($user = $DB->get_record('user', ['id' => $resp->userid])) {
- $ruser = fullname($user);
- } else {
- $ruser = '- ' . get_string('unknown', 'questionnaire') . ' -';
- }
- } else {
- $ruser = $resp->userid;
- }
- }
- } else {
- $resps = $respsallparticipants;
- }
-
- if (!empty($resps)) {
- foreach ($resps as $response) {
- questionnaire_delete_response($response, $questionnaire);
- }
- if (!$questionnaire->count_submissions()) {
- $redirection = $CFG->wwwroot . '/mod/questionnaire/view.php?id=' . $cm->id;
- } else {
- $redirection = $CFG->wwwroot . '/mod/questionnaire/report.php?action=vall&sid=' . $sid . '&instance=' .
- $instance;
- }
-
- // Log this questionnaire delete all responses action.
- $context = context_module::instance($questionnaire->cm->id);
- $anonymous = $questionnaire->respondenttype == 'anonymous';
-
- $event = \mod_questionnaire\event\all_responses_deleted::create([
- 'objectid' => $questionnaire->id,
- 'anonymous' => $anonymous,
- 'context' => $context,
- ]);
- $event->trigger();
-
- redirect($redirection);
- } else {
- $link = new \moodle_url('/mod/questionnaire/report.php', [
- 'action' => 'vall',
- 'sid' => $sid,
- 'instance' => $instance,
- ]);
- throw new \moodle_exception('couldnotdelresp', 'mod_questionnaire', $link);
- }
+ (new report_actions($questionnaire, $renderer))
+ ->delete_all_responses($groupmode, $currentgroupid, $respsallparticipants);
break;
case 'dwnpg': // Download page options.
- require_capability('mod/questionnaire:downloadresponses', $context);
-
- $PAGE->set_title(get_string('questionnairereport', 'questionnaire'));
- $PAGE->set_heading(format_string($course->fullname));
- echo $questionnaire->renderer->header();
-
- // Print the tabs.
- // Tab setup.
- if (empty($user)) {
- $SESSION->questionnaire->current_tab = 'downloadcsv';
- } else {
- $SESSION->questionnaire->current_tab = 'mydownloadcsv';
- }
-
- include('tabs.php');
-
- $groupname = '';
- if ($groupmode > 0) {
- switch ($currentgroupid) {
- case 0: // All participants.
- $groupname = get_string('allparticipants');
- break;
- default: // Members of a specific group.
- $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 . $questionnaire->renderer->help_icon('downloadtextformat', 'questionnaire'));
- $output .= $questionnaire->renderer->heading(get_string('textdownloadoptions', 'questionnaire'), 3);
- $output .= $questionnaire->renderer->box_start();
- $downloadparams = [
- 'instance' => $instance,
- 'user' => $user,
- 'sid' => $sid,
- 'action' => 'dfs',
- 'group' => $currentgroupid,
- ];
- $extrafields = $questionnaire->renderer->render_from_template('mod_questionnaire/extrafields', []);
- $output .= $questionnaire->renderer->download_dataformat_selector(
- get_string('downloadtypes', 'questionnaire'),
- 'report.php',
- 'downloadformat',
- $downloadparams,
- $extrafields
- );
- $output .= $questionnaire->renderer->box_end();
-
- $questionnaire->page->add_to_page('respondentinfo', $output);
- echo $questionnaire->renderer->render($questionnaire->page);
-
- echo $questionnaire->renderer->footer('none');
-
- // Log saved as text action.
- $params = [
- 'objectid' => $questionnaire->id,
- 'context' => $questionnaire->context,
- 'courseid' => $course->id,
- 'other' => ['action' => $action, 'instance' => $instance, 'currentgroupid' => $currentgroupid],
- ];
- $event = \mod_questionnaire\event\all_responses_saved_as_text::create($params);
- $event->trigger();
-
- exit();
+ (new report_downloader($questionnaire, $renderer))
+ ->show_download_options($page, $groupmode, $currentgroupid, $questionnairegroups ?: [], (int)$user);
break;
case 'dfs':
- require_capability('mod/questionnaire:downloadresponses', $context);
- // Use the questionnaire name as the file name. Clean it and change any non-filename characters to '_'.
- $name = clean_param($questionnaire->name, PARAM_FILE);
- $name = preg_replace("/[^A-Z0-9]+/i", "_", trim($name));
-
- $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 = $questionnaire->generate_csv(
- $currentgroupid,
- '',
- $user,
- $choicecodes,
- $choicetext,
- $showincompletes,
- $rankaverages
- );
-
- $columns = $output[0];
- unset($output[0]);
-
- // Check if email report was selected.
- $emailreport = optional_param('emailreport', '', PARAM_ALPHA);
- if (empty($emailreport)) {
- \core\dataformat::download_data($name, $dataformat, $columns, $output);
- } else {
- // Emailreport button selected.
- if (get_config('questionnaire', 'allowemailreporting') && (!empty($emailroles) || !empty($emailextra))) {
- require_once('savefileformat.php');
- $users = !empty($emailroles) ? $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);
- }
- } else {
- redirect(
- new moodle_url(
- 'report.php',
- ['instance' => $instance, 'action' => 'dwnpg', 'group' => $currentgroupid]
- ),
- get_string('emailsnotspecified', 'questionnaire')
- );
- }
- }
- exit();
+ (new report_downloader($questionnaire, $renderer))
+ ->download_responses($page, $currentgroupid, (int)$user);
break;
case 'vall': // View all responses.
case 'vallasort': // View all responses sorted in ascending order.
case 'vallarsort': // View all responses sorted in descending order.
- $PAGE->set_title(get_string('questionnairereport', 'questionnaire'));
- $PAGE->set_heading(format_string($course->fullname));
- if (!$questionnaire->capabilities->readallresponses && !$questionnaire->capabilities->readallresponseanytime) {
- echo $questionnaire->renderer->header();
- // Should never happen, unless called directly by a snoop.
- throw new \moodle_exception('nopermissions', 'mod_questionnaire');
- // Finish the page.
- echo $questionnaire->renderer->footer($course);
- break;
- }
-
- // Print the tabs.
- switch ($action) {
- case 'vallasort':
- $SESSION->questionnaire->current_tab = 'vallasort';
- break;
- case 'vallarsort':
- $SESSION->questionnaire->current_tab = 'vallarsort';
- break;
- default:
- $SESSION->questionnaire->current_tab = 'valldefault';
- }
- if ($outputtarget != 'print') {
- include('tabs.php');
- }
-
- $respinfo = '';
- $resps = [];
- // Enable choose_group if there are questionnaire groups and groupmode is not set to "no groups"
- // and if there are more goups 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 = $questionnaire->count_submissions(false, $group->id);
- $thisgroupname = groups_get_group_name($group->id);
- $escapedgroupname = preg_quote($thisgroupname, '/');
- if (!empty($respscount)) {
- // Add number of responses to name of group in the groups select list.
- $groupselect = preg_replace(
- '/\' . $escapedgroupname . '<\/option>/',
- ' ' . $thisgroupname . ' (' . $respscount . ') ',
- $groupselect
- );
- } else {
- // Remove groups with no responses from the groups select list.
- $groupselect = preg_replace(
- '/\' . $escapedgroupname . '<\/option>/',
- '',
- $groupselect
- );
- }
- }
- $respinfo .= isset($groupselect) ? ($groupselect . ' ') : '';
- $currentgroupid = groups_get_activity_group($cm);
- }
- if ($currentgroupid > 0) {
- $groupname = get_string('group') . ': ' . groups_get_group_name($currentgroupid) . ' ';
- } else {
- $groupname = '' . $responsestatus[$userview] . ' ';
- }
-
- // Available group modes (0 = no groups; 1 = separate groups; 2 = visible groups).
- if ($groupmode > 0) {
- switch ($currentgroupid) {
- case 0: // All participants.
- $resps = $respsallparticipants;
- break;
- default: // Members of a specific group.
- if (!($resps = $questionnaire->get_responses(false, $currentgroupid))) {
- $resps = '';
- }
- }
- if (empty($resps)) {
- $noresponses = true;
- }
- } else {
- $resps = $respsallparticipants;
- }
- if (!empty($resps)) {
- // NOTE: response_analysis uses $resps to get the id's of the responses only.
- // Need to figure out what this function does.
- $feedbackmessages = $questionnaire->response_analysis(0, $resps, false, false, true, $currentgroupid);
-
- if ($feedbackmessages) {
- $msgout = '';
- foreach ($feedbackmessages as $msg) {
- $msgout .= $msg;
- }
- $questionnaire->page->add_to_page('feedbackmessages', $msgout);
- }
- }
-
- $params = [
- 'objectid' => $questionnaire->id,
- 'context' => $context,
- 'courseid' => $course->id,
- 'other' => ['action' => $action, 'instance' => $instance, 'groupid' => $currentgroupid],
- ];
-
- if ($outputtarget == 'pdf') {
- $pdf = questionnaire_report_start_pdf();
- if ($currentgroupid > 0) {
- $groupname = get_string('group') . ': ' . groups_get_group_name($currentgroupid) . ' ';
- } else {
- $groupname = '' . $responsestatus[$userview] . ' ';
- }
- $respinfo = get_string('view') . ' ' . $groupname;
- $strsort = get_string('order_' . $sort, 'questionnaire');
- $respinfo .= $strsort;
- $questionnaire->page->add_to_page('respondentinfo', $respinfo);
- $questionnaire->survey_results('', false, true, $currentgroupid, $sort);
- $html = $questionnaire->renderer->render($questionnaire->page);
-
- // Supress any warnings. There is at least one error in the TCPF library at line 16749 where 'text-align' is
- // not an array.
- $errorreporting = error_reporting(0);
- $pdf->writeHTML($html);
- @$pdf->Output(clean_param($questionnaire->name, PARAM_FILE) . '.pdf', 'D');
- error_reporting($errorreporting);
- } else { // Default to HTML.
- $event = \mod_questionnaire\event\all_responses_viewed::create($params);
- $event->trigger();
-
- if ($outputtarget != 'print') {
- $linkname = get_string('downloadpdf', 'mod_questionnaire');
- $link = new moodle_url(
- '/mod/questionnaire/report.php',
- [
- 'action' => 'vall',
- 'instance' => $instance,
- 'group' => $currentgroupid,
- 'target' => 'pdf',
- 'responsestats' => $userview,
- ]
- );
- $downpdficon = new pix_icon('f/pdf', $linkname);
- $respinfo .= $questionnaire->renderer->action_link($link, null, null, null, $downpdficon);
-
- $linkname = get_string('print', 'mod_questionnaire');
- $link = new \moodle_url(
- '/mod/questionnaire/report.php',
- [
- 'action' => 'vall',
- 'instance' => $instance,
- 'group' => $currentgroupid,
- 'target' => 'print',
- 'responsestats' => $userview,
- ]
- );
- $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);
- $class = '';
- $respinfo .= $questionnaire->renderer->action_link(
- $link,
- null,
- $action,
- ['class' => $class, 'title' => $linkname],
- $htmlicon
- ) . ' ';
-
- $respinfo .= $questionnaire->renderer->viewresponse_print_menu($url->out(), $responsestatus, $userview);
- $strsort = get_string('order_' . $sort, 'questionnaire');
- $respinfo .= $strsort;
- $respinfo .= $questionnaire->renderer->help_icon('orderresponses', 'questionnaire');
- $questionnaire->page->add_to_page('respondentinfo', $respinfo);
- }
-
- $ret = $questionnaire->survey_results('', false, false, $currentgroupid, $sort);
-
- echo $questionnaire->renderer->header();
- echo $questionnaire->renderer->render($questionnaire->page);
- echo $questionnaire->renderer->footer($course);
- }
+ (new report_viewer($questionnaire, $renderer))->view_all_responses(
+ $page,
+ $action,
+ $outputtarget,
+ $url,
+ $groupmode,
+ $currentgroupid,
+ $questionnairegroups ?: [],
+ $respsallparticipants,
+ $sort,
+ $userview,
+ $responsestatus
+ );
break;
case 'vresp': // View by response.
default:
- if (empty($questionnaire->survey)) {
- throw new \moodle_exception('surveynotexists', 'mod_questionnaire');
- } else if ($questionnaire->survey->courseid != $course->id) {
- throw new \moodle_exception('surveyowner', 'mod_questionnaire');
- }
- $ruser = false;
- $noresponses = false;
- if ($usergraph) {
- $charttype = $questionnaire->survey->chart_type;
- if ($charttype) {
- $PAGE->requires->js('/mod/questionnaire/javascript/RGraph/RGraph.common.core.js');
-
- switch ($charttype) {
- case 'bipolar':
- $PAGE->requires->js('/mod/questionnaire/javascript/RGraph/RGraph.bipolar.js');
- break;
- case 'hbar':
- $PAGE->requires->js('/mod/questionnaire/javascript/RGraph/RGraph.hbar.js');
- break;
- case 'radar':
- $PAGE->requires->js('/mod/questionnaire/javascript/RGraph/RGraph.radar.js');
- break;
- case 'rose':
- $PAGE->requires->js('/mod/questionnaire/javascript/RGraph/RGraph.rose.js');
- break;
- case 'vprogress':
- $PAGE->requires->js('/mod/questionnaire/javascript/RGraph/RGraph.vprogress.js');
- break;
- }
- }
- }
-
- // Add group filter dropdown.
- if ($groupmode > 0) {
- $groupselect = groups_print_activity_menu($cm, $url->out(), true);
- $questionnaire->page->add_to_page('respondentinfo', $groupselect);
- $currentgroupid = groups_get_activity_group($cm);
- }
-
- if ($byresponse || $rid) {
- // Available group modes (0 = no groups; 1 = separate groups; 2 = visible groups).
- if ($groupmode > 0) {
- switch ($currentgroupid) {
- case 0: // All participants.
- $resps = $respsallparticipants;
- break;
- default: // Members of a specific group.
- $resps = $questionnaire->get_responses(false, $currentgroupid);
- }
- if (empty($resps)) {
- $noresponses = true;
- } else {
- if ($rid === false) {
- $resp = current($resps);
- $rid = $resp->id;
- } else {
- $resp = $DB->get_record('questionnaire_response', ['id' => $rid]);
- }
- if (!empty($resp->userid)) {
- if ($user = $DB->get_record('user', ['id' => $resp->userid])) {
- $ruser = fullname($user);
- } else {
- $ruser = '- ' . get_string('unknown', 'questionnaire') . ' -';
- }
- } else {
- $ruser = $resp->userid;
- }
- }
- } else {
- $resps = $respsallparticipants;
- }
- }
- $rids = array_keys($resps);
- if (!$rid && !$noresponses) {
- $rid = $rids[0];
- }
-
- if ($outputtarget == 'pdf') {
- $pdf = questionnaire_report_start_pdf();
- if ($currentgroupid > 0) {
- $groupname = get_string('group') . ': ' . groups_get_group_name($currentgroupid) . ' ';
- } else {
- $groupname = '' . $responsestatus[$userview] . ' ';
- }
- if (!$byresponse) { // Show respondents individual responses.
- $questionnaire->view_response($rid, '', $resps, true, true, false, $currentgroupid, $outputtarget);
- }
- $html = $questionnaire->renderer->render($questionnaire->page);
- // Supress any warnings. There is at least one error in the TCPF library at line 16749 where 'text-align' is
- // not an array.
- $errorreporting = error_reporting(0);
- $pdf->writeHTML($html);
- @$pdf->Output(clean_param($questionnaire->name, PARAM_FILE), 'D');
- error_reporting($errorreporting);
- } else { // Default to HTML.
- if ($noresponses) {
- $questionnaire->page->add_to_page(
- 'respondentinfo',
- get_string('group') . ' ' .
- groups_get_group_name($currentgroupid) . ' : ' . get_string('noresponses', 'questionnaire')
- );
- }
-
- // Print the page header.
- $PAGE->set_title(get_string('questionnairereport', 'questionnaire'));
- $PAGE->set_heading(format_string($course->fullname));
-
- // Print the tabs.
- if ($byresponse) {
- $SESSION->questionnaire->current_tab = 'vrespsummary';
- }
- if ($individualresponse) {
- $SESSION->questionnaire->current_tab = 'individualresp';
- }
- if ($outputtarget == 'html') {
- include('tabs.php');
- }
-
- // Print the main part of the page.
- // TODO provide option to select how many columns and/or responses per page.
-
- $groupname = get_string('group') . ': ' . groups_get_group_name($currentgroupid) . ' ';
- if ($currentgroupid == 0) {
- $groupname = $responsestatus[$userview];
- }
- if ($byresponse) {
- $respinfo = '';
- $respinfo .= $questionnaire->renderer->box_start();
- $respinfo .= $questionnaire->renderer->help_icon('viewindividualresponse', 'questionnaire') . ' ';
- $respinfo .= get_string('viewindividualresponse', 'questionnaire') . ' : ' . $groupname . ' ';
- $respinfo .= $questionnaire->renderer->box_end();
- $questionnaire->page->add_to_page('respondentinfo', $respinfo);
- }
- if ($outputtarget == 'html') {
- $questionnaire->survey_results_navbar_alpha($rid, $currentgroupid, $cm, $byresponse);
- }
- if (!$byresponse && $questionnaire->can_view_response($rid)) { // Show respondents individual responses.
- $questionnaire->view_response($rid, '', $resps, true, true, false, $currentgroupid, $outputtarget);
- }
- echo $questionnaire->renderer->header();
- echo $questionnaire->renderer->render($questionnaire->page);
- echo $questionnaire->renderer->footer($course);
- }
+ (new report_viewer($questionnaire, $renderer))->view_individual_response(
+ $page,
+ $outputtarget,
+ $url,
+ $groupmode,
+ $currentgroupid,
+ $respsallparticipants,
+ $rid,
+ (bool)$byresponse,
+ (bool)$individualresponse,
+ $userview,
+ $responsestatus
+ );
break;
}
-
-/**
- * Return a pdf object.
- *
- * @package mod_questionnaire
- * @copyright 2016 Mike Churchward (mike.churchward@poetgroup.org)
- * @author Mike Churchward
- * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
- *
- * @return pdf
- */
-function questionnaire_report_start_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);
- // Set default monospaced font.
- $pdf->SetDefaultMonospacedFont(PDF_FONT_MONOSPACED);
-
- // Set margins.
- $pdf->SetMargins(PDF_MARGIN_LEFT, PDF_MARGIN_TOP, PDF_MARGIN_RIGHT);
- $pdf->SetHeaderMargin(PDF_MARGIN_HEADER);
- $pdf->SetFooterMargin(PDF_MARGIN_FOOTER);
-
- // Set auto page breaks.
- $pdf->SetAutoPageBreak(true, PDF_MARGIN_BOTTOM);
-
- // Set image scale factor.
- $pdf->setImageScale(PDF_IMAGE_SCALE_RATIO);
- // Set background color for headings.
- $pdf->SetFillColor(238, 238, 238);
- $pdf->AddPage('L');
- return $pdf;
-}
diff --git a/savefileformat.php b/savefileformat.php
deleted file mode 100644
index 7cdc4b0b2..000000000
--- a/savefileformat.php
+++ /dev/null
@@ -1,121 +0,0 @@
-.
-
-/**
- * savefileformat.php - Replaces dataformatlib.php to capture output into files.
- *
- * @package mod_questionnaire
- * @copyright 2019 onward Mike Churchward (mike.churchward@poetopensource.org)
- * @author Mike Churchward
- * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
- */
-
-/**
- * Sends a formated data file to the browser and optionally a file. This is needed until the main data format API provides a way
- * to ouput a file as well as stream to the browser. This file relies on capturing output buffers (ugly hack).
- *
- * @param string $filename The base filename without an extension
- * @param string $dataformat A dataformat name
- * @param array $columns An ordered map of column keys and labels
- * @param Iterator $iterator An iterator over the records, usually a RecordSet
- * @param array $users
- * @param array $emails
- * @param string $redirect
- */
-function save_as_dataformat($filename, $dataformat, $columns, $iterator, $users = [], $emails = [], $redirect = '') {
- global $CFG, $OUTPUT;
-
- $classname = 'dataformat_' . $dataformat . '\writer';
- if (!class_exists($classname)) {
- throw new coding_exception("Unable to locate dataformat/$dataformat/classes/writer.php");
- }
- $format = new $classname();
-
- // The data format export could take a while to generate...
- set_time_limit(0);
-
- // Close the session so that the users other tabs in the same session are not blocked.
- \core\session\manager::write_close();
-
- $format->set_filename($filename);
- // File creation for any data format is initiated by "send_http_headers()". This is required. But, this also will cause the
- // browser to respond with a "save / open" dialogue. To get rid of the dialogue, immediately retract the headers with
- // "header_remove()".
- $format->send_http_headers();
- header_remove();
-
- // Start capturing output to write to a file.
- ob_start();
- // This exists to support all dataformats - see MDL-56046.
- if (method_exists($format, 'write_header')) {
- debugging('The function write_header() does not support multiple sheets. In order to support multiple sheets you ' .
- 'must implement start_output() and start_sheet() and remove write_header() in your dataformat.', DEBUG_DEVELOPER);
- $format->write_header($columns);
- } else {
- $format->start_output();
- $format->start_sheet($columns);
- }
- $c = 0;
- foreach ($iterator as $row) {
- if ($row === null) {
- continue;
- }
- $format->write_record($row, $c++);
- }
- // This exists to support all dataformats - see MDL-56046.
- if (method_exists($format, 'write_footer')) {
- debugging('The function write_footer() does not support multiple sheets. In order to support multiple sheets you ' .
- 'must implement close_sheet() and close_output() and remove write_footer() in your dataformat.', DEBUG_DEVELOPER);
- $format->write_footer($columns);
- } else {
- $format->close_sheet($columns);
- $format->close_output();
- $output = ob_get_contents();
- $ext = $format->get_extension();
- $filepath = make_temp_directory('mod_questionnaire') . '/' . $filename . $ext;
- $fp = fopen($filepath, 'wb');
- fwrite($fp, $output);
- fclose($fp);
- $subjecttext = get_string('summaryreportattached', 'questionnaire');
- foreach ($users as $user) {
- email_to_user(
- $user,
- $CFG->noreplyaddress,
- $subjecttext,
- $subjecttext,
- '',
- $filepath,
- $filename . $ext
- );
- }
- foreach ($emails as $email) {
- $email = trim($email);
- $user = (object)['id' => -10, 'email' => $email, 'firstname' => $email, 'lastname' => $email, 'mailformat' => 1];
- email_to_user(
- $user,
- $CFG->noreplyaddress,
- $subjecttext,
- $subjecttext,
- '',
- $filepath,
- $filename . $ext
- );
- }
- unlink($filepath);
- }
- ob_end_clean();
- echo $OUTPUT->redirect_message($redirect, get_string('emailssent', 'questionnaire'), 3, false);
-}
diff --git a/settings.php b/settings.php
index 39ac51c48..309947d1d 100644
--- a/settings.php
+++ b/settings.php
@@ -24,7 +24,6 @@
*/
defined('MOODLE_INTERNAL') || die;
-require_once($CFG->dirroot . '/mod/questionnaire/locallib.php');
if ($ADMIN->fulltree) {
$options = [0 => get_string('no'), 1 => get_string('yes')];
@@ -99,7 +98,7 @@
$setting = new admin_setting_configselect('questionnaire/autodeleteresponse', $name, $desc, 0, $options);
$settings->add($setting);
- $options = questionnaire_create_remove_options();
+ $options = \mod_questionnaire\form_options::response_removal();
$settings->add(
new admin_setting_configselect(
'questionnaire/removeoldresponses',
diff --git a/show_nonrespondents.php b/show_nonrespondents.php
index d05df86e1..f6bd414c8 100644
--- a/show_nonrespondents.php
+++ b/show_nonrespondents.php
@@ -24,10 +24,12 @@
*/
require_once("../../config.php");
-require_once($CFG->dirroot . '/mod/questionnaire/locallib.php');
-require_once($CFG->dirroot . '/mod/questionnaire/questionnaire.class.php');
require_once($CFG->libdir . '/tablelib.php');
+use mod_questionnaire\questionnaire;
+use mod_questionnaire\local\response\questionnaire_responses;
+use mod_questionnaire\output\nonrespondentspage;
+
// Get the params.
$id = required_param('id', PARAM_INT);
$subject = optional_param('subject', '', PARAM_CLEANHTML);
@@ -36,37 +38,16 @@
$messageuser = optional_param_array('messageuser', false, PARAM_INT);
$action = optional_param('action', '', PARAM_ALPHA);
$selectedanonymous = optional_param('selectedanonymous', '', PARAM_ALPHA);
-$perpage = optional_param('perpage', QUESTIONNAIRE_DEFAULT_PAGE_COUNT, PARAM_INT); // How many per page.
+$perpage = optional_param('perpage', questionnaire::default_page_count(), PARAM_INT); // How many per page.
$showall = optional_param('showall', false, PARAM_INT); // Should we show all users?
$sid = optional_param('sid', 0, PARAM_INT);
$qid = optional_param('qid', 0, PARAM_INT);
$currentgroupid = optional_param('group', 0, PARAM_INT); // Groupid.
-if (!isset($SESSION->questionnaire)) {
- $SESSION->questionnaire = new stdClass();
-}
-
-$SESSION->questionnaire->current_tab = 'nonrespondents';
-
-// Get the objects.
-
-if ($id) {
- if (! $cm = get_coursemodule_from_id('questionnaire', $id)) {
- throw new \moodle_exception('invalidcoursemodule', 'mod_questionnaire');
- }
-
- if (! $course = $DB->get_record("course", ["id" => $cm->course])) {
- throw new \moodle_exception('coursemisconf', 'mod_questionnaire');
- }
-
- if (! $questionnaire = $DB->get_record("questionnaire", ["id" => $cm->instance])) {
- throw new \moodle_exception('invalidcoursemodule', 'mod_questionnaire');
- }
-}
-
-if (!$context = context_module::instance($cm->id)) {
- throw new \moodle_exception('badcontext', 'mod_questionnaire');
-}
+$questionnaire = questionnaire::from_cmid($id);
+$course = $questionnaire->course();
+$cm = $questionnaire->coursemodule();
+$context = $questionnaire->context();
// We need the coursecontext to allow sending of mass mails.
if (!$coursecontext = context_course::instance($course->id)) {
@@ -78,15 +59,12 @@
$url = new moodle_url('/mod/questionnaire/show_nonrespondents.php', ['id' => $cm->id]);
$PAGE->set_url($url);
-$questionnaire = new questionnaire($course, $cm, $sid, $questionnaire);
-
-// Add renderer and page objects to the questionnaire object for display use.
-$questionnaire->add_renderer($PAGE->get_renderer('mod_questionnaire'));
-$questionnaire->add_page(new \mod_questionnaire\output\nonrespondentspage());
+$renderer = $PAGE->get_renderer('mod_questionnaire');
+$page = new nonrespondentspage();
-$resume = $questionnaire->resume;
-$fullname = $questionnaire->respondenttype == 'fullname';
-$sid = $questionnaire->sid;
+$resume = $questionnaire->resume();
+$fullname = $questionnaire->respondenttype() == 'fullname';
+$sid = $questionnaire->surveyid();
if (($formdata = data_submitted()) && !confirm_sesskey()) {
throw new \moodle_exception('invalidsesskey', 'mod_questionnaire');
@@ -96,12 +74,12 @@
// Anonymous questionnaire.
if (!$fullname) {
- $nonrespondents = questionnaire_get_incomplete_users($cm, $sid);
+ $nonrespondents = questionnaire_responses::get_incomplete_users($cm, $sid);
$countnonrespondents = count($nonrespondents);
if ($resume) {
$countstarted = 0;
$countnotstarted = 0;
- $params = ['questionnaireid' => $questionnaire->id, 'complete' => 'n'];
+ $params = ['questionnaireid' => $questionnaire->id(), 'complete' => 'n'];
if ($startedusers = $DB->get_records('questionnaire_response', $params, '', 'userid')) {
$startedusers = array_keys($startedusers);
$countstarted = count($startedusers);
@@ -139,7 +117,7 @@
$link1 = $CFG->wwwroot . '/mod/questionnaire/view.php?id= ' . $cm->id;
$htmlmessage .= '';
$htmlmessage .= $message;
@@ -164,9 +142,9 @@
$good = $good && message_send($eventdata);
}
if (!empty($good)) {
- $msg = $questionnaire->renderer->heading(get_string('messagedselectedusers'));
+ $msg = $renderer->heading(get_string('messagedselectedusers'));
} else {
- $msg = $questionnaire->renderer->heading(get_string('messagedselectedusersfailed'));
+ $msg = $renderer->heading(get_string('messagedselectedusersfailed'));
}
$url = new moodle_url('/mod/questionnaire/view.php', ['id' => $cm->id]);
@@ -179,10 +157,10 @@
// Print the page header.
$PAGE->navbar->add(get_string('show_nonrespondents', 'questionnaire'));
$PAGE->set_heading(format_string($course->fullname));
-$PAGE->set_title(format_string($questionnaire->name));
-echo $questionnaire->renderer->header();
+$PAGE->set_title(format_string($questionnaire->name()));
+echo $renderer->header();
-require('tabs.php');
+(new \mod_questionnaire\output\tabs($questionnaire, 'nonrespondents', $currentgroupid))->render($page);
$usedgroupid = false;
$sort = '';
@@ -276,7 +254,7 @@
} else {
$usedgroupid = false;
}
- $nonrespondents = questionnaire_get_incomplete_users($cm, $sid, $usedgroupid);
+ $nonrespondents = questionnaire_responses::get_incomplete_users($cm, $sid, $usedgroupid);
if (is_array($nonrespondents) || is_object($nonrespondents)) {
$countnonrespondents = count($nonrespondents);
} else {
@@ -295,14 +273,14 @@
}
}
-$nonrespondents = questionnaire_get_incomplete_users($cm, $sid, $usedgroupid, $sort, $startpage, $pagecount);
+$nonrespondents = questionnaire_responses::get_incomplete_users($cm, $sid, $usedgroupid, $sort, $startpage, $pagecount);
// Viewreports-start.
// Print the list of students.
-$questionnaire->page->add_to_page('formarea', (isset($groupselect) ? $groupselect : ''));
-$questionnaire->page->add_to_page('formarea', html_writer::tag('div', '', ['class' => 'clearer']));
-$questionnaire->page->add_to_page('formarea', $questionnaire->renderer->box_start('left-align'));
+$page->add_to_page('formarea', (isset($groupselect) ? $groupselect : ''));
+$page->add_to_page('formarea', html_writer::tag('div', '', ['class' => 'clearer']));
+$page->add_to_page('formarea', $renderer->box_start('left-align'));
$countries = get_string_manager()->get_list_of_countries();
@@ -321,17 +299,17 @@
$datestring->secs = get_string('secs');
if (!$nonrespondents) {
- $questionnaire->page->add_to_page(
+ $page->add_to_page(
'formarea',
- $questionnaire->renderer->notification(get_string('noexistingparticipants', 'enrol'))
+ $renderer->notification(get_string('noexistingparticipants', 'enrol'))
);
} else {
- $questionnaire->page->add_to_page('formarea', get_string('non_respondents', 'questionnaire'));
- $questionnaire->page->add_to_page('formarea', ' (' . $countnonrespondents . ')');
+ $page->add_to_page('formarea', get_string('non_respondents', 'questionnaire'));
+ $page->add_to_page('formarea', ' (' . $countnonrespondents . ')');
if (!$fullname) {
- $questionnaire->page->add_to_page('formarea', ' [' . get_string('anonymous', 'questionnaire') . ']');
+ $page->add_to_page('formarea', ' [' . get_string('anonymous', 'questionnaire') . ']');
}
- $questionnaire->page->add_to_page(
+ $page->add_to_page(
'formarea',
html_writer::start_tag(
'form',
@@ -354,7 +332,7 @@
// Userpicture and link to the profilepage.
$profileurl = $CFG->wwwroot . '/user/view.php?id=' . $user->id . '&course=' . $course->id;
$profilelink = '' . fullname($user) . ' ';
- $data = [$questionnaire->renderer->user_picture($user, ['courseid' => $course->id]), $profilelink];
+ $data = [$renderer->user_picture($user, ['courseid' => $course->id]), $profilelink];
if (in_array('email', $tablecolumns)) {
$data[] = $user->email;
}
@@ -378,7 +356,7 @@
if (
$DB->record_exists(
'questionnaire_response',
- ['questionnaireid' => $questionnaire->id, 'userid' => $nonrespondent, 'complete' => 'n']
+ ['questionnaireid' => $questionnaire->id(), 'userid' => $nonrespondent, 'complete' => 'n']
)
) {
$data[] = get_string('started', 'questionnaire');
@@ -395,26 +373,26 @@
}
if (isset($table)) {
- $questionnaire->page->add_to_page('formarea', $questionnaire->renderer->flexible_table($table, $buffering));
+ $page->add_to_page('formarea', $renderer->flexible_table($table, $buffering));
} else if ($buffering) {
ob_end_clean();
}
$allurl = new moodle_url($baseurl);
if ($showall) {
$allurl->param('showall', 0);
- $questionnaire->page->add_to_page(
+ $page->add_to_page(
'formarea',
- $questionnaire->renderer->container(
- html_writer::link($allurl, get_string('showperpage', '', QUESTIONNAIRE_DEFAULT_PAGE_COUNT)),
+ $renderer->container(
+ html_writer::link($allurl, get_string('showperpage', '', questionnaire::default_page_count())),
[],
'showall'
)
);
} else if ($countnonrespondents > 0 && $perpage < $countnonrespondents) {
$allurl->param('showall', 1);
- $questionnaire->page->add_to_page(
+ $page->add_to_page(
'formarea',
- $questionnaire->renderer->container(
+ $renderer->container(
html_writer::link($allurl, get_string('showall', '', $countnonrespondents)),
[],
'showall'
@@ -423,52 +401,52 @@
}
if (has_capability('mod/questionnaire:message', $context)) {
// Selection buttons container.
- $questionnaire->page->add_to_page('formarea', $questionnaire->renderer->box_start('mdl-align'));
- $questionnaire->page->add_to_page('formarea', '');
+ $page->add_to_page('formarea', $renderer->box_end());
if ($action == 'sendmessage' && !is_array($messageuser)) {
- $questionnaire->page->add_to_page(
+ $page->add_to_page(
'formarea',
- $questionnaire->renderer->notification(get_string('nousersselected', 'questionnaire'))
+ $renderer->notification(get_string('nousersselected', 'questionnaire'))
);
}
}
} else {// Anonymous questionnaire.
if (has_capability('mod/questionnaire:message', $context)) {
- $questionnaire->page->add_to_page('formarea', '');
- $questionnaire->page->add_to_page('formarea', '' . get_string('send_message_to', 'questionnaire') .
+ $page->add_to_page('formarea', ' ');
+ $page->add_to_page('formarea', '' . get_string('send_message_to', 'questionnaire') .
' ');
$checked = ($selectedanonymous == '' || $selectedanonymous == 'none') ? 'checked = "checked"' : '';
- $questionnaire->page->add_to_page(
+ $page->add_to_page(
'formarea',
'
' . get_string('none') . ' '
);
$checked = ($selectedanonymous == 'all') ? 'checked = "checked"' : '';
- $questionnaire->page->add_to_page(
+ $page->add_to_page(
'formarea',
'
' . get_string('all', 'questionnaire') . ' '
@@ -476,7 +454,7 @@
if ($resume) {
if ($countstarted > 0) {
$checked = ($selectedanonymous == 'started') ? 'checked = "checked"' : '';
- $questionnaire->page->add_to_page(
+ $page->add_to_page(
'formarea',
'
' . get_string('status') . ': ' .
@@ -490,7 +468,7 @@
$checked = '';
}
$checked = ($selectedanonymous == 'notstarted') ? 'checked = "checked"' : '';
- $questionnaire->page->add_to_page(
+ $page->add_to_page(
'formarea',
'
' . get_string('status') . ': ' .
@@ -499,22 +477,22 @@
}
}
if ($action == 'sendmessage' && $selectedanonymous == 'none') {
- $questionnaire->page->add_to_page(
+ $page->add_to_page(
'formarea',
- $questionnaire->renderer->notification(get_string('nousersselected', 'questionnaire'))
+ $renderer->notification(get_string('nousersselected', 'questionnaire'))
);
}
- $questionnaire->page->add_to_page('formarea', ' ');
+ $page->add_to_page('formarea', ' ');
}
}
if (has_capability('mod/questionnaire:message', $context)) {
// Message editor.
// Prepare data.
- $questionnaire->page->add_to_page('formarea', '');
+ $page->add_to_page('formarea', '');
if ($action == 'sendmessage' && (empty($subject) || empty($message))) {
- $questionnaire->page->add_to_page('formarea', $questionnaire->renderer->notification(get_string('allfieldsrequired')));
+ $page->add_to_page('formarea', $renderer->notification(get_string('allfieldsrequired')));
}
- $questionnaire->page->add_to_page(
+ $page->add_to_page(
'formarea',
'' . get_string('send_message', 'questionnaire') . ' '
);
@@ -523,7 +501,7 @@
maxlength="255" name="subject" value="' . $subject . '" />';
$format = '';
$editor = editors_get_preferred_editor();
- $editor->use_editor($id, questionnaire_get_editor_options($context));
+ $editor->use_editor($id, \mod_questionnaire\form_options::editor($context));
$texteditor = html_writer::tag(
'div',
html_writer::tag(
@@ -532,7 +510,7 @@
['id' => $id, 'name' => "message", 'class' => "form-control", 'rows' => '10', 'cols' => '60']
)
);
- $questionnaire->page->add_to_page('formarea', ' ');
+ $page->add_to_page('formarea', ' ');
// Print editor.
@@ -541,44 +519,41 @@
$table->data[] = ['' . get_string('subject', 'questionnaire') . ' ', $subjecteditor];
$table->data[] = ['' . get_string('messagebody') . ' ', $texteditor];
- $questionnaire->page->add_to_page('formarea', html_writer::table($table));
+ $page->add_to_page('formarea', html_writer::table($table));
// Send button.
- $questionnaire->page->add_to_page('formarea', $questionnaire->renderer->box_start('mdl-left'));
- $questionnaire->page->add_to_page('formarea', '');
- $questionnaire->page->add_to_page('formarea', ' ');
+ $page->add_to_page('formarea', ' ');
- $questionnaire->page->add_to_page('formarea', '
');
- $questionnaire->page->add_to_page('formarea', $questionnaire->renderer->box_end());
+ $page->add_to_page('formarea', '');
+ $page->add_to_page('formarea', $renderer->box_end());
- $questionnaire->page->add_to_page('formarea', ' ');
- $questionnaire->page->add_to_page('formarea', ' ');
- $questionnaire->page->add_to_page('formarea', ' ');
+ $page->add_to_page('formarea', ' ');
+ $page->add_to_page('formarea', ' ');
+ $page->add_to_page('formarea', ' ');
- $questionnaire->page->add_to_page('formarea', ' ');
+ $page->add_to_page('formarea', ' ');
- $questionnaire->page->add_to_page('formarea', html_writer::end_tag('form'));
+ $page->add_to_page('formarea', html_writer::end_tag('form'));
- // Include the needed js.
- $module = ['name' => 'mod_questionnaire', 'fullpath' => '/mod/questionnaire/module.js'];
- $PAGE->requires->js_init_call('M.mod_questionnaire.init_sendmessage', null, false, $module);
+ $PAGE->requires->js_call_amd('mod_questionnaire/sendmessage', 'init');
}
}
-$questionnaire->page->add_to_page('formarea', $questionnaire->renderer->box_end());
+$page->add_to_page('formarea', $renderer->box_end());
// Finish the page.
-echo $questionnaire->renderer->render($questionnaire->page);
-echo $questionnaire->renderer->footer();
+echo $renderer->render($page);
+echo $renderer->footer();
// Log this questionnaire show non-respondents action.
-$context = context_module::instance($questionnaire->cm->id);
-$anonymous = $questionnaire->respondenttype == 'anonymous';
+$anonymous = $questionnaire->respondenttype() == 'anonymous';
$event = \mod_questionnaire\event\non_respondents_viewed::create(
[
- 'objectid' => $questionnaire->id,
+ 'objectid' => $questionnaire->id(),
'anonymous' => $anonymous,
- 'context' => $context,
+ 'context' => $questionnaire->context(),
]
);
$event->trigger();
diff --git a/tabs.php b/tabs.php
deleted file mode 100644
index aa3fabcab..000000000
--- a/tabs.php
+++ /dev/null
@@ -1,331 +0,0 @@
-.
-
-/**
- * prints the tabbed bar
- *
- * @package mod_questionnaire
- * @copyright 2016 Mike Churchward (mike.churchward@poetgroup.org)
- * @author Mike Churchward
- * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
- */
-
-defined('MOODLE_INTERNAL') || die();
-
-global $DB, $SESSION;
-$tabs = [];
-$row = [];
-$inactive = [];
-$activated = [];
-if (!isset($SESSION->questionnaire)) {
- $SESSION->questionnaire = new stdClass();
-}
-$currenttab = $SESSION->questionnaire->current_tab;
-
-// In a questionnaire instance created "using" a PUBLIC questionnaire, prevent anyone from editing settings, editing questions,
-// viewing all responses...except in the course where that PUBLIC questionnaire was originally created.
-
-$owner = $questionnaire->is_survey_owner();
-if ($questionnaire->capabilities->manage && $owner) {
- $row[] = new tabobject(
- 'settings',
- $CFG->wwwroot . htmlspecialchars('/mod/questionnaire/qsettings.php?' . 'id=' . $questionnaire->cm->id),
- get_string('advancedsettings')
- );
-}
-
-if ($questionnaire->capabilities->editquestions && $owner) {
- $row[] = new tabobject(
- 'questions',
- $CFG->wwwroot . htmlspecialchars('/mod/questionnaire/questions.php?' . 'id=' . $questionnaire->cm->id),
- get_string('questions', 'questionnaire')
- );
-}
-
-if ($questionnaire->capabilities->editquestions && $owner) {
- $row[] = new tabobject(
- 'feedback',
- $CFG->wwwroot . htmlspecialchars('/mod/questionnaire/feedback.php?' . 'id=' . $questionnaire->cm->id),
- get_string('feedback')
- );
-}
-
-if ($questionnaire->capabilities->preview && $owner) {
- if (!empty($questionnaire->questions)) {
- $row[] = new tabobject(
- 'preview',
- $CFG->wwwroot . htmlspecialchars('/mod/questionnaire/preview.php?' . 'id=' . $questionnaire->cm->id),
- get_string('preview_label', 'questionnaire')
- );
- }
-}
-
-$usernumresp = $questionnaire->count_submissions($USER->id);
-
-if ($questionnaire->capabilities->readownresponses && ($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->downloadresponses) {
- $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();
-// Number of responses in currently selected group (or all participants etc.).
-if (isset($SESSION->questionnaire->numselectedresps)) {
- $numselectedresps = $SESSION->questionnaire->numselectedresps;
-} else {
- $numselectedresps = $numresp;
-}
-
-// 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($cm, $course);
-if ($groupmode == 1) {
- $canviewgroups = groups_has_membership($cm, $USER->id);
-}
-$canviewallgroups = has_capability('moodle/site:accessallgroups', $context);
-$grouplogic = $canviewallgroups || $canviewgroups;
-$resplogic = ($numresp > 0) && ($numselectedresps > 0);
-
-if ($questionnaire->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->viewsingleresponse) {
- $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->deleteresponses) {
- $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->downloadresponses) {
- $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->deleteresponses) {
- $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->can_view_all_responses_with_restrictions($usernumresp, $grouplogic, $resplogic)) {
- $argstr = 'instance=' . $questionnaire->id . '&sid=' . $questionnaire->sid;
- $row[] = new tabobject(
- 'allreport',
- $CFG->wwwroot . htmlspecialchars('/mod/questionnaire/report.php?' . $argstr . '&action=vall&group=' . $currentgroupid),
- 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->deleteresponses) {
- $argstr2 = $argstr . '&action=delallresp';
- $row2[] = new tabobject(
- 'deleteall',
- $CFG->wwwroot . htmlspecialchars('/mod/questionnaire/report.php?' . $argstr2),
- get_string('deleteallresponses', 'questionnaire')
- );
- }
-
- if ($questionnaire->capabilities->downloadresponses) {
- $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->viewsingleresponse && ($canviewallgroups || $canviewgroups)) {
- $nonrespondenturl = new moodle_url('/mod/questionnaire/show_nonrespondents.php', ['id' => $questionnaire->cm->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;
- }
-
- $questionnaire->page->add_to_page('tabsarea', print_tabs($tabs, $currenttab, $inactive, $activated, true));
-}
diff --git a/templates/completepage.mustache b/templates/completepage.mustache
index 67ee253f9..5dbf2189d 100644
--- a/templates/completepage.mustache
+++ b/templates/completepage.mustache
@@ -20,7 +20,7 @@
Template which defines a questionnaire completion page.
Classes required for JS:
- * /mod/questionnaire/module.js
+ * none
Data attributes required for JS:
* none
diff --git a/templates/dependencylist.mustache b/templates/dependencylist.mustache
new file mode 100644
index 000000000..35adb7bf9
--- /dev/null
+++ b/templates/dependencylist.mustache
@@ -0,0 +1,41 @@
+{{!
+ This file is part of Moodle - http://moodle.org/
+
+ Moodle is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Moodle is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with Moodle. If not, see .
+}}
+{{!
+ @template mod_questionnaire/dependencylist
+
+ Displays the list of parent question dependencies for a question.
+
+ Context variables required for this template:
+
+ Example context (json):
+ {
+ "dependencies": [
+ {
+ "divid": "qdepend_7_3_1",
+ "divclass": "qdepend",
+ "dependquestion": "Dependent on question",
+ "position": "Position",
+ "parentposition": "2",
+ "parent": "Parent question text",
+ "logicstr": "set"
+ }
+ ]
+ }
+}}
+{{#dependencies}}
+{{dependquestion}} : {{position}} {{parentposition}} ({{parent}}) {{logicstr}}
+{{/dependencies}}
diff --git a/templates/fbsectionspage.mustache b/templates/fbsectionspage.mustache
index f10cfb0b9..c76f8b97d 100644
--- a/templates/fbsectionspage.mustache
+++ b/templates/fbsectionspage.mustache
@@ -20,7 +20,7 @@
Template which defines a questionnaire completion page.
Classes required for JS:
- * /mod/questionnaire/module.js
+ * none
Data attributes required for JS:
* none
diff --git a/templates/navbaralpha.mustache b/templates/navbaralpha.mustache
index 468b43154..9183e4725 100644
--- a/templates/navbaralpha.mustache
+++ b/templates/navbaralpha.mustache
@@ -20,7 +20,7 @@
Template which defines a questionnaire all responses navigation bar for the report page.
Classes required for JS:
- * /mod/questionnaire/module.js
+ * none
Data attributes required for JS:
* none
diff --git a/templates/navbaruser.mustache b/templates/navbaruser.mustache
index 5e85d6d0b..96779e67c 100644
--- a/templates/navbaruser.mustache
+++ b/templates/navbaruser.mustache
@@ -20,7 +20,7 @@
Template which defines a questionnaire user responses navigation bar for the report page.
Classes required for JS:
- * /mod/questionnaire/module.js
+ * none
Data attributes required for JS:
* none
diff --git a/templates/nonrespondentspage.mustache b/templates/nonrespondentspage.mustache
index f1325ac44..71b17f57c 100644
--- a/templates/nonrespondentspage.mustache
+++ b/templates/nonrespondentspage.mustache
@@ -20,7 +20,7 @@
Template which defines a questionnaire completion page.
Classes required for JS:
- * /mod/questionnaire/module.js
+ * none
Data attributes required for JS:
* none
diff --git a/templates/progressbar.mustache b/templates/progressbar.mustache
index cfd1e0015..6dbdf1042 100644
--- a/templates/progressbar.mustache
+++ b/templates/progressbar.mustache
@@ -20,7 +20,7 @@
Template which defines a questionnaire completion page.
Classes required for JS:
- * /questionnaire/module.js
+ * none
Data attributes required for JS:
* none
diff --git a/templates/qsettingspage.mustache b/templates/qsettingspage.mustache
index e08af44b3..35106d76d 100644
--- a/templates/qsettingspage.mustache
+++ b/templates/qsettingspage.mustache
@@ -20,7 +20,7 @@
Template which defines a questionnaire completion page.
Classes required for JS:
- * /mod/questionnaire/module.js
+ * none
Data attributes required for JS:
* none
diff --git a/templates/question_check.mustache b/templates/question_check.mustache
index 1cf39c574..fe88605b7 100644
--- a/templates/question_check.mustache
+++ b/templates/question_check.mustache
@@ -20,7 +20,7 @@
Template which defines a check type question survey display.
Classes required for JS:
- * /mod/questionnaire/module.js
+ * none
Data attributes required for JS:
* none
@@ -59,7 +59,7 @@
{{{choice.label}}}
- {{#choice.oname}} {{/choice.oname}}
{{/choice}}
diff --git a/templates/question_container.mustache b/templates/question_container.mustache
index 97ebd1a96..a18629c45 100644
--- a/templates/question_container.mustache
+++ b/templates/question_container.mustache
@@ -20,7 +20,7 @@
Template which defines a check type question survey display.
Classes required for JS:
- * /mod/questionnaire/module.js
+ * none
Data attributes required for JS:
* none
diff --git a/templates/question_date.mustache b/templates/question_date.mustache
index f677b5dd8..22d1efc39 100644
--- a/templates/question_date.mustache
+++ b/templates/question_date.mustache
@@ -20,7 +20,7 @@
Template which defines a date type question survey display.
Classes required for JS:
- * /mod/questionnaire/module.js
+ * none
Data attributes required for JS:
* none
diff --git a/templates/question_drop.mustache b/templates/question_drop.mustache
index 794b06f59..13c9edfd4 100644
--- a/templates/question_drop.mustache
+++ b/templates/question_drop.mustache
@@ -20,7 +20,7 @@
Template which defines a radio button type question survey display.
Classes required for JS:
- * /mod/questionnaire/module.js
+ * none
Data attributes required for JS:
* none
diff --git a/templates/question_numeric.mustache b/templates/question_numeric.mustache
index df7ed86fd..b04558d4d 100644
--- a/templates/question_numeric.mustache
+++ b/templates/question_numeric.mustache
@@ -20,7 +20,7 @@
Template which defines a numeric type question survey display.
Classes required for JS:
- * /mod/questionnaire/module.js
+ * none
Data attributes required for JS:
* none
diff --git a/templates/question_radio.mustache b/templates/question_radio.mustache
index be8bf5712..6542dba1f 100644
--- a/templates/question_radio.mustache
+++ b/templates/question_radio.mustache
@@ -20,7 +20,7 @@
Template which defines a radio button type question survey display.
Classes required for JS:
- * /mod/questionnaire/module.js
+ * none
Data attributes required for JS:
* none
@@ -72,7 +72,7 @@
{{#choice.onclick}}onclick="{{choice.onclick}}"{{/choice.onclick}} aria-label="{{alabel}}">
{{{choice.label}}}
{{#choice.oname}}
-
{{{choice.olabel}}}
{{/choice.oname}}
diff --git a/templates/question_rate.mustache b/templates/question_rate.mustache
index a9fb3365d..5661faccc 100644
--- a/templates/question_rate.mustache
+++ b/templates/question_rate.mustache
@@ -20,7 +20,7 @@
Template which defines a rate type question survey display.
Classes required for JS:
- * /mod/questionnaire/module.js
+ * none
Data attributes required for JS:
* none
@@ -142,7 +142,7 @@
{{#coltext}}{{{.}}}{{/coltext}}
{{#oname}} {{/oname}}
{{#colhiddentext}}{{{.}}} {{/colhiddentext}}
- {{#colinput}}
+ {{#colinput}}
{{#label}}{{{.}}} {{/label}}{{/colinput}}
{{/cols}}
diff --git a/templates/question_sectionfb.mustache b/templates/question_sectionfb.mustache
index ee6db1876..7caa00816 100644
--- a/templates/question_sectionfb.mustache
+++ b/templates/question_sectionfb.mustache
@@ -20,7 +20,7 @@
Template which defines a sectiontext type question survey display.
Classes required for JS:
- * /mod/questionnaire/module.js
+ * none
Data attributes required for JS:
* none
diff --git a/templates/question_slider.mustache b/templates/question_slider.mustache
index 31ed6102f..098b3744f 100644
--- a/templates/question_slider.mustache
+++ b/templates/question_slider.mustache
@@ -20,7 +20,7 @@
Template which defines a slider type question survey display.
Classes required for JS:
- * /mod/questionnaire/module.js
+ * none
Data attributes required for JS:
* none
diff --git a/templates/question_text.mustache b/templates/question_text.mustache
index 55ab2dd2c..c4cce768a 100644
--- a/templates/question_text.mustache
+++ b/templates/question_text.mustache
@@ -20,7 +20,7 @@
Template which defines a text type question survey display.
Classes required for JS:
- * /mod/questionnaire/module.js
+ * none
Data attributes required for JS:
* none
diff --git a/templates/question_yesno.mustache b/templates/question_yesno.mustache
index d6426f0d6..ef376c5c8 100644
--- a/templates/question_yesno.mustache
+++ b/templates/question_yesno.mustache
@@ -20,7 +20,7 @@
Template which defines a yes/no type question survey display.
Classes required for JS:
- * /mod/questionnaire/module.js
+ * none
Data attributes required for JS:
* none
diff --git a/templates/questionpdf_container.mustache b/templates/questionpdf_container.mustache
index cffc32f3e..4850f243c 100644
--- a/templates/questionpdf_container.mustache
+++ b/templates/questionpdf_container.mustache
@@ -21,7 +21,7 @@
translated into a PDF file by the TCPDF library.
Classes required for JS:
- * /mod/questionnaire/module.js
+ * none
Data attributes required for JS:
* none
diff --git a/templates/questionspage.mustache b/templates/questionspage.mustache
index 2925f5f41..9798abd18 100644
--- a/templates/questionspage.mustache
+++ b/templates/questionspage.mustache
@@ -20,7 +20,7 @@
Template which defines a questionnaire completion page.
Classes required for JS:
- * /mod/questionnaire/module.js
+ * none
Data attributes required for JS:
* none
diff --git a/templates/reportpage.mustache b/templates/reportpage.mustache
index 01d1e102e..99415171e 100644
--- a/templates/reportpage.mustache
+++ b/templates/reportpage.mustache
@@ -20,7 +20,7 @@
Template which defines a questionnaire completion page.
Classes required for JS:
- * /mod/questionnaire/module.js
+ * none
Data attributes required for JS:
* none
diff --git a/templates/reportpagepdf.mustache b/templates/reportpagepdf.mustache
index 34ca4d97f..636d8de28 100644
--- a/templates/reportpagepdf.mustache
+++ b/templates/reportpagepdf.mustache
@@ -21,7 +21,7 @@
translated into a PDF file by the TCPDF library.
Classes required for JS:
- * /mod/questionnaire/module.js
+ * none
Data attributes required for JS:
* none
diff --git a/templates/response_check.mustache b/templates/response_check.mustache
index 39660d6b6..d5c6378d9 100644
--- a/templates/response_check.mustache
+++ b/templates/response_check.mustache
@@ -20,7 +20,7 @@
Template which defines a check type question survey display.
Classes required for JS:
- * /mod/questionnaire/module.js
+ * none
Data attributes required for JS:
* none
diff --git a/templates/response_container.mustache b/templates/response_container.mustache
index 78c50fcd4..5bbda5dfa 100644
--- a/templates/response_container.mustache
+++ b/templates/response_container.mustache
@@ -20,7 +20,7 @@
Template which defines a survey response display.
Classes required for JS:
- * /mod/questionnaire/module.js
+ * none
Data attributes required for JS:
* none
diff --git a/templates/response_date.mustache b/templates/response_date.mustache
index bfb8c21d0..1e283bda1 100644
--- a/templates/response_date.mustache
+++ b/templates/response_date.mustache
@@ -20,7 +20,7 @@
Template which defines a check type question survey display.
Classes required for JS:
- * /mod/questionnaire/module.js
+ * none
Data attributes required for JS:
* none
diff --git a/templates/response_drop.mustache b/templates/response_drop.mustache
index a60f0490b..356c77be5 100644
--- a/templates/response_drop.mustache
+++ b/templates/response_drop.mustache
@@ -20,7 +20,7 @@
Template which defines a check type question survey display.
Classes required for JS:
- * /mod/questionnaire/module.js
+ * none
Data attributes required for JS:
* none
diff --git a/templates/response_numeric.mustache b/templates/response_numeric.mustache
index dff09e544..4264b6e97 100644
--- a/templates/response_numeric.mustache
+++ b/templates/response_numeric.mustache
@@ -20,7 +20,7 @@
Template which defines a check type question survey display.
Classes required for JS:
- * /mod/questionnaire/module.js
+ * none
Data attributes required for JS:
* none
diff --git a/templates/response_radio.mustache b/templates/response_radio.mustache
index c5fcc61ef..8c12e0c4a 100644
--- a/templates/response_radio.mustache
+++ b/templates/response_radio.mustache
@@ -20,7 +20,7 @@
Template which defines a check type question survey display.
Classes required for JS:
- * /mod/questionnaire/module.js
+ * none
Data attributes required for JS:
* none
diff --git a/templates/response_rate.mustache b/templates/response_rate.mustache
index f102aad0d..984af9cd8 100644
--- a/templates/response_rate.mustache
+++ b/templates/response_rate.mustache
@@ -20,7 +20,7 @@
Template which defines a rate type response survey display.
Classes required for JS:
- * /mod/questionnaire/module.js
+ * none
Data attributes required for JS:
* none
diff --git a/templates/response_slider.mustache b/templates/response_slider.mustache
index 224eaaef6..b2f7a3d91 100644
--- a/templates/response_slider.mustache
+++ b/templates/response_slider.mustache
@@ -20,7 +20,7 @@
Template which defines a text type question survey display.
Classes required for JS:
- * /mod/questionnaire/module.js
+ * none
Data attributes required for JS:
* none
diff --git a/templates/response_text.mustache b/templates/response_text.mustache
index 9360d12b4..2a793a95c 100644
--- a/templates/response_text.mustache
+++ b/templates/response_text.mustache
@@ -20,7 +20,7 @@
Template which defines a text type question survey display.
Classes required for JS:
- * /mod/questionnaire/module.js
+ * none
Data attributes required for JS:
* none
diff --git a/templates/response_yesno.mustache b/templates/response_yesno.mustache
index 595a99ad3..f038c3b7e 100644
--- a/templates/response_yesno.mustache
+++ b/templates/response_yesno.mustache
@@ -20,7 +20,7 @@
Template which defines a yesno type question survey display.
Classes required for JS:
- * /mod/questionnaire/module.js
+ * none
Data attributes required for JS:
* none
diff --git a/templates/responselist.mustache b/templates/responselist.mustache
index f5d2c548f..e3e49f804 100644
--- a/templates/responselist.mustache
+++ b/templates/responselist.mustache
@@ -20,7 +20,7 @@
Template which defines a questionnaire response list on the report page.
Classes required for JS:
- * /mod/questionnaire/module.js
+ * none
Data attributes required for JS:
* none
diff --git a/templates/responsepagepdf.mustache b/templates/responsepagepdf.mustache
index ae0b23d42..af680d32f 100644
--- a/templates/responsepagepdf.mustache
+++ b/templates/responsepagepdf.mustache
@@ -21,7 +21,7 @@
translated into a PDF file by the TCPDF library.
Classes required for JS:
- * /mod/questionnaire/module.js
+ * none
Data attributes required for JS:
* none
diff --git a/templates/results_choice.mustache b/templates/results_choice.mustache
index a602a64d4..36f4c264d 100644
--- a/templates/results_choice.mustache
+++ b/templates/results_choice.mustache
@@ -20,7 +20,7 @@
Template which defines a results display for responses with defined choices.
Classes required for JS:
- * /mod/questionnaire/module.js
+ * none
Data attributes required for JS:
* none
diff --git a/templates/results_date.mustache b/templates/results_date.mustache
index cb4010b1e..0328a0645 100644
--- a/templates/results_date.mustache
+++ b/templates/results_date.mustache
@@ -20,7 +20,7 @@
Template which defines a results display for responses with dates.
Classes required for JS:
- * /mod/questionnaire/module.js
+ * none
Data attributes required for JS:
* none
diff --git a/templates/results_rate.mustache b/templates/results_rate.mustache
index b20776b9a..75df8f906 100644
--- a/templates/results_rate.mustache
+++ b/templates/results_rate.mustache
@@ -20,7 +20,7 @@
Template which defines a results display for responses with defined choices.
Classes required for JS:
- * /mod/questionnaire/module.js
+ * none
Data attributes required for JS:
* none
diff --git a/templates/results_text.mustache b/templates/results_text.mustache
index 36edb281f..a9b777ea5 100644
--- a/templates/results_text.mustache
+++ b/templates/results_text.mustache
@@ -20,7 +20,7 @@
Template which defines a results display for text responses.
Classes required for JS:
- * /mod/questionnaire/module.js
+ * none
Data attributes required for JS:
* none
diff --git a/templates/resultspdf_choice.mustache b/templates/resultspdf_choice.mustache
index c793eedb1..e09886750 100644
--- a/templates/resultspdf_choice.mustache
+++ b/templates/resultspdf_choice.mustache
@@ -21,7 +21,7 @@
suitable for being translated into a PDF file by the TCPDF library.
Classes required for JS:
- * /mod/questionnaire/module.js
+ * none
Data attributes required for JS:
* none
diff --git a/templates/resultspdf_date.mustache b/templates/resultspdf_date.mustache
index a5d2224ee..d40c638e6 100644
--- a/templates/resultspdf_date.mustache
+++ b/templates/resultspdf_date.mustache
@@ -21,7 +21,7 @@
suitable for being translated into a PDF file by the TCPDF library.
Classes required for JS:
- * /mod/questionnaire/module.js
+ * none
Data attributes required for JS:
* none
diff --git a/templates/resultspdf_rate.mustache b/templates/resultspdf_rate.mustache
index f5d83f1cb..a6eb37340 100644
--- a/templates/resultspdf_rate.mustache
+++ b/templates/resultspdf_rate.mustache
@@ -20,7 +20,7 @@
Template which defines a results display for responses with defined choices.
Classes required for JS:
- * /mod/questionnaire/module.js
+ * none
Data attributes required for JS:
* none
diff --git a/templates/resultspdf_text.mustache b/templates/resultspdf_text.mustache
index 96e7f4a0b..23d8a811f 100644
--- a/templates/resultspdf_text.mustache
+++ b/templates/resultspdf_text.mustache
@@ -21,7 +21,7 @@
translated into a PDF file by the TCPDF library.
Classes required for JS:
- * /mod/questionnaire/module.js
+ * none
Data attributes required for JS:
* none
diff --git a/tests/behat/add_questions.feature b/tests/behat/add_questions.feature
index 4f020693b..8ff595b4b 100644
--- a/tests/behat/add_questions.feature
+++ b/tests/behat/add_questions.feature
@@ -97,7 +97,7 @@ Feature: Add questions to a questionnaire activity
| Question Text | Choose yes or no |
Then I should see "[Yes/No] (Q9)"
And I should see "Choose yes or no"
- And I set the field "id_type_id" to "----- Page Break -----"
+ And I set the field "id_typeid" to "----- Page Break -----"
And I press "Add selected question type"
And I add a "File" question and I fill the form with:
| Question Name | Q10 |
diff --git a/tests/behat/behat_mod_questionnaire.php b/tests/behat/behat_mod_questionnaire.php
index a0d42e20b..87612c530 100644
--- a/tests/behat/behat_mod_questionnaire.php
+++ b/tests/behat/behat_mod_questionnaire.php
@@ -33,7 +33,6 @@
Behat\Gherkin\Node\PyStringNode,
Behat\Mink\Exception\ExpectationException;
-#[\AllowDynamicProperties]
/**
* Questionnaire-related steps definitions.
*
@@ -43,6 +42,13 @@
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class behat_mod_questionnaire extends behat_base {
+ /** @var array Maps old question IDs to new IDs when importing test data. */
+ public $questionmap = [];
+ /** @var array Maps old choice IDs to new IDs when importing test data. */
+ public $choicemap = [];
+ /** @var array Maps old response IDs to new IDs when importing test data. */
+ public $responsemap = [];
+
/**
* Convert page names to URLs for steps like 'When I am on the "[page name]" page'.
*
@@ -164,7 +170,7 @@ public function i_add_a_question_and_i_fill_the_form_with($questiontype, TableNo
$fielddata = new TableNode($rows);
}
- $this->execute('behat_forms::i_set_the_field_to', ['id_type_id', $questiontype]);
+ $this->execute('behat_forms::i_set_the_field_to', ['id_typeid', $questiontype]);
$this->execute('behat_forms::press_button', 'Add selected question type');
if (isset($hashrows['id_dependquestions_and_1'])) {
$this->execute('behat_forms::press_button', 'id_adddependencies_and');
@@ -240,7 +246,7 @@ private function add_question_data($sid) {
"id",
"surveyid",
"name",
- "type_id",
+ "typeid",
"result_id",
"length",
"precise",
@@ -312,7 +318,7 @@ private function add_question_data($sid) {
];
$choicedata = [
- ["id", "question_id", "content", "value"],
+ ["id", "questionid", "content", "value"],
["1", "7", "1", null],
["2", "7", "2", null],
["3", "7", "3", null],
@@ -338,7 +344,7 @@ private function add_question_data($sid) {
];
$this->add_data($questiondata, 'questionnaire_question', 'questionmap');
- $this->add_data($choicedata, 'questionnaire_quest_choice', 'choicemap', ['questionmap' => 'question_id']);
+ $this->add_data($choicedata, 'questionnaire_quest_choice', 'choicemap', ['questionmap' => 'questionid']);
}
/**
@@ -364,7 +370,7 @@ private function add_response_data($qid, $sid) {
$this->add_data($responses, 'questionnaire_response', 'responsemap');
$responsebool = [
- ["id", "response_id", "question_id", "choice_id"],
+ ["id", "responseid", "questionid", "choiceid"],
["", "1", "1", "y"],
["", "1", "4", "n"],
["", "2", "1", "y"],
@@ -384,11 +390,11 @@ private function add_response_data($qid, $sid) {
$responsebool,
'questionnaire_response_bool',
'',
- ['responsemap' => 'response_id', 'questionmap' => 'question_id']
+ ['responsemap' => 'responseid', 'questionmap' => 'questionid']
);
$responsedate = [
- ["id", "response_id", "question_id", "response"],
+ ["id", "responseid", "questionid", "response"],
["", "1", "8", "2014-12-19"],
["", "2", "8", "2015-12-02"],
["", "3", "8", "2015-12-04"],
@@ -400,11 +406,11 @@ private function add_response_data($qid, $sid) {
$responsedate,
'questionnaire_response_date',
'',
- ['responsemap' => 'response_id', 'questionmap' => 'question_id']
+ ['responsemap' => 'responseid', 'questionmap' => 'questionid']
);
$responseother = [
- ["id", "response_id", "question_id", "choice_id", "response"],
+ ["id", "responseid", "questionid", "choiceid", "response"],
["", "5", "7", "21", "Forty-four"],
["", "6", "12", "22", "Green"],
["", "7", "7", "21", "5"],
@@ -413,11 +419,11 @@ private function add_response_data($qid, $sid) {
$responseother,
'questionnaire_response_other',
'',
- ['responsemap' => 'response_id', 'questionmap' => 'question_id', 'choicemap' => 'choice_id']
+ ['responsemap' => 'responseid', 'questionmap' => 'questionid', 'choicemap' => 'choiceid']
);
$responserank = [
- ["id", "response_id", "question_id", "choice_id", "rankvalue"],
+ ["id", "responseid", "questionid", "choiceid", "rankvalue"],
["", "1", "13", "16", "0"],
["", "1", "13", "17", "1"],
["", "1", "13", "18", "2"],
@@ -458,11 +464,11 @@ private function add_response_data($qid, $sid) {
$responserank,
'questionnaire_response_rank',
'',
- ['responsemap' => 'response_id', 'questionmap' => 'question_id', 'choicemap' => 'choice_id']
+ ['responsemap' => 'responseid', 'questionmap' => 'questionid', 'choicemap' => 'choiceid']
);
$respmultiple = [
- ["id", "response_id", "question_id", "choice_id"],
+ ["id", "responseid", "questionid", "choiceid"],
["", "1", "7", "1"],
["", "1", "7", "3"],
["", "1", "7", "5"],
@@ -483,11 +489,11 @@ private function add_response_data($qid, $sid) {
$respmultiple,
'questionnaire_resp_multiple',
'',
- ['responsemap' => 'response_id', 'questionmap' => 'question_id', 'choicemap' => 'choice_id']
+ ['responsemap' => 'responseid', 'questionmap' => 'questionid', 'choicemap' => 'choiceid']
);
$respsingle = [
- ["id", "response_id", "question_id", "choice_id"],
+ ["id", "responseid", "questionid", "choiceid"],
["", "1", "9", "7"],
["", "1", "12", "15"],
["", "2", "9", "7"],
@@ -505,7 +511,7 @@ private function add_response_data($qid, $sid) {
$respsingle,
'questionnaire_resp_single',
'',
- ['responsemap' => 'response_id', 'questionmap' => 'question_id', 'choicemap' => 'choice_id']
+ ['responsemap' => 'responseid', 'questionmap' => 'questionid', 'choicemap' => 'choiceid']
);
}
diff --git a/tests/behat/check_responses_capabilities.feature b/tests/behat/check_responses_capabilities.feature
index 348d88708..c99088d02 100644
--- a/tests/behat/check_responses_capabilities.feature
+++ b/tests/behat/check_responses_capabilities.feature
@@ -84,7 +84,7 @@ Feature: Review responses with different capabilities
| mod/questionnaire:readallresponseanytime | Prohibit |
| mod/questionnaire:readallresponses | Allow |
And the following "activities" exist:
- | activity | name | description | course | idnumber | resp_view |
+ | activity | name | description | course | idnumber | respview |
| questionnaire | Test questionnaire | Test questionnaire description | C1 | questionnaire0 | 0 |
| questionnaire | Test questionnaire 2 | Test questionnaire 2 description | C1 | questionnaire2 | 3 |
And "Test questionnaire" has questions and responses
diff --git a/tests/behat/dependency_question.feature b/tests/behat/dependency_question.feature
index a35213892..09aecf13f 100644
--- a/tests/behat/dependency_question.feature
+++ b/tests/behat/dependency_question.feature
@@ -74,7 +74,7 @@ Feature: Questions can be defined to be dependent on answers to multiple previou
And I am on "Course 1" course homepage
And I follow "Test questionnaire 2"
And I navigate to "Questions" in current page administration
- And I set the field "id_type_id" to "Dropdown Box"
+ And I set the field "id_typeid" to "Dropdown Box"
And I press "Add selected question type"
And I set the field "Question Name" to "Q1"
And I set the field "No" to "n"
diff --git a/tests/behat/feedback_usergraph.feature b/tests/behat/feedback_usergraph.feature
new file mode 100644
index 000000000..6320eff53
--- /dev/null
+++ b/tests/behat/feedback_usergraph.feature
@@ -0,0 +1,63 @@
+@mod @mod_questionnaire
+Feature: When the questionnaire usergraph site setting is enabled and a feedback section has a chart type set, the student's response view renders the RGraph chart canvas alongside the feedback text.
+ In order to verify the chart rendering pipeline
+ As a developer
+ I need a Behat scenario that exercises the usergraph code path.
+
+ Background:
+ Given the following config values are set as admin:
+ | usergraph | 1 | questionnaire |
+ And the following "users" exist:
+ | username | firstname | lastname | email |
+ | teacher1 | Teacher | 1 | teacher1@example.com |
+ | student1 | Student | 1 | student1@example.com |
+ And the following "courses" exist:
+ | fullname | shortname | category |
+ | Course 1 | C1 | 0 |
+ And the following "course enrolments" exist:
+ | user | course | role |
+ | teacher1 | C1 | editingteacher |
+ | student1 | C1 | student |
+ And the following "activities" exist:
+ | activity | name | description | course | idnumber | resume | navigate |
+ | questionnaire | Test questionnaire | Test questionnaire | C1 | questionnaire0 | 1 | 1 |
+
+ @javascript
+ Scenario: A submitted response renders the chart canvas in the feedback view.
+ Given I log in as "teacher1"
+ And I am on "Course 1" course homepage
+ And I follow "Test questionnaire"
+ And I navigate to "Questions" in current page administration
+ And I add a "Radio Buttons" question and I fill the form with:
+ | Question Name | Q1 |
+ | Yes | y |
+ | Horizontal | Checked |
+ | Question Text | Pick one |
+ | Possible answers | 1=One,2=Two,3=Three |
+ Then I should see "[Radio Buttons] (Q1)"
+ And I follow "Feedback"
+ And I set the field "id_feedbacksections" to "Global Feedback"
+ And I set the field "id_feedbackscores" to "Yes"
+ And I set the field "id_chart_type_global" to "Bipolar bars"
+ And I set the field "id_feedbacknotes" to "Feedback notes here"
+ And I press "Save settings and edit Feedback Sections"
+ Then I should see "Global Feedback heading"
+ And I set the field "id_sectionlabel" to "Global feedback label"
+ And I set the field "id_sectionheading" to "Global section heading"
+ And I set the field "id_feedbacktext_0" to "Feedback band A"
+ And I set the field "id_feedbackboundaries_0" to "50"
+ And I set the field "id_feedbacktext_1" to "Feedback band B"
+ And I press "Save changes"
+ And I log out
+ When I log in as "student1"
+ And I am on "Course 1" course homepage
+ And I follow "Test questionnaire"
+ And I navigate to "Answer the questions..." in current page administration
+ And I click on "Three" "radio"
+ And I press "Submit questionnaire"
+ Then I should see "Thank you for completing this Questionnaire."
+ And I press "Continue"
+ Then I should see "Feedback notes here"
+ And I should see "Global feedback label"
+ And "//canvas[starts-with(@id, 'questionnaire-chart-')]" "xpath_element" should exist
+ And I log out
diff --git a/tests/capabilities_test.php b/tests/capabilities_test.php
new file mode 100644
index 000000000..14f12be04
--- /dev/null
+++ b/tests/capabilities_test.php
@@ -0,0 +1,452 @@
+.
+
+/**
+ * Unit tests for the mod_questionnaire capabilities helper.
+ *
+ * @package mod_questionnaire
+ * @copyright 2026 onward 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;
+
+use mod_questionnaire\local\db\questionnaire_record;
+use mod_questionnaire\local\db\survey_record;
+
+defined('MOODLE_INTERNAL') || die();
+
+global $CFG;
+require_once($CFG->dirroot . '/mod/questionnaire/lib.php');
+require_once($CFG->dirroot . '/mod/questionnaire/tests/questionnaire_testable.php');
+
+/**
+ * Unit tests for {@see capabilities}.
+ *
+ * @group mod_questionnaire
+ * @covers \mod_questionnaire\capabilities
+ */
+final class capabilities_test extends \advanced_testcase {
+ /**
+ * Build a questionnaire_record persistent populated from an in-memory object (no DB read).
+ *
+ * @param array $fields
+ * @return questionnaire_record
+ */
+ private function make_module_record(array $fields = []): questionnaire_record {
+ return new questionnaire_record(0, (object)array_merge([
+ 'id' => 1,
+ 'course' => 1,
+ 'name' => 'Test questionnaire',
+ 'intro' => '',
+ 'introformat' => FORMAT_HTML,
+ 'qtype' => questionnaire::QTYPE_UNLIMITED,
+ 'respondenttype' => 'fullname',
+ 'respeligible' => 'all',
+ 'respview' => questionnaire::RESPVIEW_NEVER,
+ 'notifications' => 0,
+ 'opendate' => 0,
+ 'closedate' => 0,
+ 'resume' => 0,
+ 'navigate' => 0,
+ 'grade' => 0,
+ 'sid' => 1,
+ 'timemodified' => 0,
+ 'completionsubmit' => 0,
+ 'autonum' => 3,
+ 'progressbar' => 0,
+ 'removeafter' => 0,
+ ], $fields));
+ }
+
+ /**
+ * Build a survey_record persistent from an in-memory object (no DB read).
+ *
+ * @param array $fields
+ * @return survey_record
+ */
+ private function make_survey_record(array $fields = []): survey_record {
+ return new survey_record(0, (object)array_merge([
+ 'id' => 1,
+ 'name' => 'Test survey',
+ 'courseid' => 1,
+ 'realm' => 'private',
+ 'status' => 0,
+ 'title' => '',
+ 'email' => null,
+ 'subtitle' => null,
+ 'info' => null,
+ 'theme' => null,
+ 'thankspage' => null,
+ 'thankhead' => null,
+ 'thankbody' => null,
+ 'feedbacksections' => 0,
+ 'feedbacknotes' => null,
+ 'feedbackscores' => 0,
+ 'charttype' => null,
+ ], $fields));
+ }
+
+ /**
+ * Return a questionnaire_testable with optional field overrides.
+ *
+ * @param array $modulefields Overrides for questionnaire_record.
+ * @param array $surveyfields Overrides for survey_record.
+ * @return questionnaire_testable
+ */
+ private function make_questionnaire(array $modulefields = [], array $surveyfields = []): questionnaire_testable {
+ if (!array_key_exists('id', $surveyfields)) {
+ $surveyfields['id'] = $modulefields['sid'] ?? 1;
+ }
+ return new questionnaire_testable(
+ $this->make_module_record($modulefields),
+ $this->make_survey_record($surveyfields)
+ );
+ }
+
+ // Tests for user_time_for_new_attempt().
+
+ /**
+ * Asserts user_time_for_new_attempt() always returns true for unlimited questionnaires.
+ */
+ public function test_user_time_unlimited_always_allowed(): void {
+ $q = $this->make_questionnaire(['qtype' => questionnaire::QTYPE_UNLIMITED]);
+ $this->assertTrue($q->capabilities()->user_time_for_new_attempt(1));
+ }
+
+ /**
+ * Asserts user_time_for_new_attempt() returns true when no prior responses exist.
+ */
+ public function test_user_time_with_no_previous_responses(): void {
+ $this->resetAfterTest();
+ $q = $this->make_questionnaire(['id' => 88881, 'qtype' => questionnaire::QTYPE_ONCE]);
+ $this->assertTrue($q->capabilities()->user_time_for_new_attempt(1));
+ }
+
+ /**
+ * Asserts user_time_for_new_attempt() returns false for ONCE when a prior response exists.
+ */
+ public function test_user_time_once_blocked_by_existing_response(): void {
+ global $DB;
+ $this->resetAfterTest();
+
+ $qid = 88882;
+ $q = $this->make_questionnaire(['id' => $qid, 'qtype' => questionnaire::QTYPE_ONCE]);
+ $DB->insert_record('questionnaire_response', (object)[
+ 'questionnaireid' => $qid, 'userid' => 1,
+ 'submitted' => time() - 3600, 'complete' => 'y', 'grade' => 0,
+ ]);
+
+ $this->assertFalse($q->capabilities()->user_time_for_new_attempt(1));
+ }
+
+ /**
+ * Asserts user_time_for_new_attempt() returns false for DAILY when a same-day response exists.
+ */
+ public function test_user_time_daily_blocked_same_day(): void {
+ global $DB;
+ $this->resetAfterTest();
+
+ $qid = 88883;
+ $q = $this->make_questionnaire(['id' => $qid, 'qtype' => questionnaire::QTYPE_DAILY]);
+ $DB->insert_record('questionnaire_response', (object)[
+ 'questionnaireid' => $qid, 'userid' => 1,
+ 'submitted' => mktime(0, 0, 0, (int)date('n'), (int)date('j'), (int)date('Y')),
+ 'complete' => 'y', 'grade' => 0,
+ ]);
+
+ $this->assertFalse($q->capabilities()->user_time_for_new_attempt(1));
+ }
+
+ /**
+ * Asserts user_time_for_new_attempt() returns true for DAILY when the prior response is from a previous day.
+ */
+ public function test_user_time_daily_allowed_previous_day(): void {
+ global $DB;
+ $this->resetAfterTest();
+
+ $qid = 88884;
+ $q = $this->make_questionnaire(['id' => $qid, 'qtype' => questionnaire::QTYPE_DAILY]);
+ $DB->insert_record('questionnaire_response', (object)[
+ 'questionnaireid' => $qid, 'userid' => 1,
+ 'submitted' => mktime(0, 0, 0, date('n'), date('j') - 1, date('Y')),
+ 'complete' => 'y', 'grade' => 0,
+ ]);
+
+ $this->assertTrue($q->capabilities()->user_time_for_new_attempt(1));
+ }
+
+ /**
+ * Asserts user_time_for_new_attempt() returns false for WEEKLY when a same-week response exists.
+ */
+ public function test_user_time_weekly_blocked_same_week(): void {
+ global $DB;
+ $this->resetAfterTest();
+
+ $qid = 88885;
+ $q = $this->make_questionnaire(['id' => $qid, 'qtype' => questionnaire::QTYPE_WEEKLY]);
+ $DB->insert_record('questionnaire_response', (object)[
+ 'questionnaireid' => $qid, 'userid' => 1,
+ 'submitted' => time() - DAYSECS, 'complete' => 'y', 'grade' => 0,
+ ]);
+
+ // Blocked because the attempt was in the same ISO week.
+ if (date('W', time() - DAYSECS) === date('W', time())) {
+ $this->assertFalse($q->capabilities()->user_time_for_new_attempt(1));
+ } else {
+ $this->markTestSkipped('Response crossed a week boundary during test execution.');
+ }
+ }
+
+ /**
+ * Asserts user_time_for_new_attempt() returns false for MONTHLY when a same-month response exists.
+ */
+ public function test_user_time_monthly_blocked_same_month(): void {
+ global $DB;
+ $this->resetAfterTest();
+
+ $qid = 88886;
+ $q = $this->make_questionnaire(['id' => $qid, 'qtype' => questionnaire::QTYPE_MONTHLY]);
+ $DB->insert_record('questionnaire_response', (object)[
+ 'questionnaireid' => $qid, 'userid' => 1,
+ 'submitted' => mktime(0, 0, 0, date('n'), 1, date('Y')),
+ 'complete' => 'y', 'grade' => 0,
+ ]);
+
+ $this->assertFalse($q->capabilities()->user_time_for_new_attempt(1));
+ }
+
+ /**
+ * Asserts user_time_for_new_attempt() returns true for MONTHLY when the prior response is from a previous month.
+ */
+ public function test_user_time_monthly_allowed_previous_month(): void {
+ global $DB;
+ $this->resetAfterTest();
+
+ $qid = 88887;
+ $q = $this->make_questionnaire(['id' => $qid, 'qtype' => questionnaire::QTYPE_MONTHLY]);
+ $DB->insert_record('questionnaire_response', (object)[
+ 'questionnaireid' => $qid, 'userid' => 1,
+ 'submitted' => mktime(0, 0, 0, date('n') - 1, 1, date('Y')),
+ 'complete' => 'y', 'grade' => 0,
+ ]);
+
+ $this->assertTrue($q->capabilities()->user_time_for_new_attempt(1));
+ }
+
+ // Tests for capability methods (require a real course module and context).
+
+ /**
+ * Asserts can_manage_questionnaire() returns true for an editing teacher and false for a student.
+ */
+ public function test_can_manage_questionnaire_teacher_vs_student(): void {
+ global $DB;
+ $this->resetAfterTest();
+ $this->setAdminUser();
+
+ $generator = $this->getDataGenerator();
+ $course = $generator->create_course();
+ $instance = $generator->get_plugin_generator('mod_questionnaire')->create_instance(['course' => $course->id]);
+
+ $teacher = $generator->create_user();
+ $student = $generator->create_user();
+ $teacherrole = $DB->get_record('role', ['shortname' => 'editingteacher'], '*', MUST_EXIST);
+ $studentrole = $DB->get_record('role', ['shortname' => 'student'], '*', MUST_EXIST);
+ $generator->enrol_user($teacher->id, $course->id, $teacherrole->id);
+ $generator->enrol_user($student->id, $course->id, $studentrole->id);
+
+ $this->assertTrue($instance->capabilities()->can_manage_questionnaire($teacher->id));
+ $this->assertFalse($instance->capabilities()->can_manage_questionnaire($student->id));
+ }
+
+ /**
+ * Asserts can_edit_questions() returns true for an editing teacher and false for a student.
+ */
+ public function test_can_edit_questions_teacher_vs_student(): void {
+ global $DB;
+ $this->resetAfterTest();
+ $this->setAdminUser();
+
+ $generator = $this->getDataGenerator();
+ $course = $generator->create_course();
+ $instance = $generator->get_plugin_generator('mod_questionnaire')->create_instance(['course' => $course->id]);
+
+ $teacher = $generator->create_user();
+ $student = $generator->create_user();
+ $teacherrole = $DB->get_record('role', ['shortname' => 'editingteacher'], '*', MUST_EXIST);
+ $studentrole = $DB->get_record('role', ['shortname' => 'student'], '*', MUST_EXIST);
+ $generator->enrol_user($teacher->id, $course->id, $teacherrole->id);
+ $generator->enrol_user($student->id, $course->id, $studentrole->id);
+
+ $this->assertTrue($instance->capabilities()->can_edit_questions($teacher->id));
+ $this->assertFalse($instance->capabilities()->can_edit_questions($student->id));
+ }
+
+ /**
+ * Asserts can_view() returns true for the current user when set to a student.
+ */
+ public function test_can_view_returns_true_for_enrolled_user(): void {
+ global $DB;
+ $this->resetAfterTest();
+ $this->setAdminUser();
+
+ $generator = $this->getDataGenerator();
+ $course = $generator->create_course();
+ $instance = $generator->get_plugin_generator('mod_questionnaire')->create_instance(['course' => $course->id]);
+
+ $student = $generator->create_user();
+ $studentrole = $DB->get_record('role', ['shortname' => 'student'], '*', MUST_EXIST);
+ $generator->enrol_user($student->id, $course->id, $studentrole->id);
+
+ $this->setUser($student);
+ $this->assertTrue($instance->capabilities()->can_view());
+ }
+
+ /**
+ * Asserts can_preview() returns true for a teacher and false for a student.
+ */
+ public function test_can_preview_teacher_vs_student(): void {
+ global $DB;
+ $this->resetAfterTest();
+ $this->setAdminUser();
+
+ $generator = $this->getDataGenerator();
+ $course = $generator->create_course();
+ $instance = $generator->get_plugin_generator('mod_questionnaire')->create_instance(['course' => $course->id]);
+
+ $teacher = $generator->create_user();
+ $student = $generator->create_user();
+ $teacherrole = $DB->get_record('role', ['shortname' => 'teacher'], '*', MUST_EXIST);
+ $studentrole = $DB->get_record('role', ['shortname' => 'student'], '*', MUST_EXIST);
+ $generator->enrol_user($teacher->id, $course->id, $teacherrole->id);
+ $generator->enrol_user($student->id, $course->id, $studentrole->id);
+
+ $this->setUser($teacher);
+ $this->assertTrue($instance->capabilities()->can_preview());
+ $this->setUser($student);
+ $this->assertFalse($instance->capabilities()->can_preview());
+ }
+
+ /**
+ * Asserts user_is_eligible() returns true for an enrolled student and false for a guest.
+ */
+ public function test_user_is_eligible_enrolled_vs_unenrolled(): void {
+ global $DB;
+ $this->resetAfterTest();
+ $this->setAdminUser();
+
+ $generator = $this->getDataGenerator();
+ $course = $generator->create_course();
+ $instance = $generator->get_plugin_generator('mod_questionnaire')->create_instance(['course' => $course->id]);
+
+ $student = $generator->create_user();
+ $guest = $generator->create_user();
+ $studentrole = $DB->get_record('role', ['shortname' => 'student'], '*', MUST_EXIST);
+ $generator->enrol_user($student->id, $course->id, $studentrole->id);
+
+ $this->assertTrue($instance->capabilities()->user_is_eligible($student->id));
+ $this->assertFalse($instance->capabilities()->user_is_eligible($guest->id));
+ }
+
+ /**
+ * Asserts can_read_own_responses() returns true for a student (default role capability).
+ */
+ public function test_can_read_own_responses_for_enrolled_student(): void {
+ global $DB;
+ $this->resetAfterTest();
+ $this->setAdminUser();
+
+ $generator = $this->getDataGenerator();
+ $course = $generator->create_course();
+ $instance = $generator->get_plugin_generator('mod_questionnaire')->create_instance(['course' => $course->id]);
+
+ $student = $generator->create_user();
+ $studentrole = $DB->get_record('role', ['shortname' => 'student'], '*', MUST_EXIST);
+ $generator->enrol_user($student->id, $course->id, $studentrole->id);
+
+ $this->assertTrue($instance->capabilities()->can_read_own_responses($student->id));
+ }
+
+ /**
+ * Asserts can_delete_responses() returns true for an editing teacher and false for a student.
+ */
+ public function test_can_delete_responses_teacher_vs_student(): void {
+ global $DB;
+ $this->resetAfterTest();
+ $this->setAdminUser();
+
+ $generator = $this->getDataGenerator();
+ $course = $generator->create_course();
+ $instance = $generator->get_plugin_generator('mod_questionnaire')->create_instance(['course' => $course->id]);
+
+ $teacher = $generator->create_user();
+ $student = $generator->create_user();
+ $teacherrole = $DB->get_record('role', ['shortname' => 'editingteacher'], '*', MUST_EXIST);
+ $studentrole = $DB->get_record('role', ['shortname' => 'student'], '*', MUST_EXIST);
+ $generator->enrol_user($teacher->id, $course->id, $teacherrole->id);
+ $generator->enrol_user($student->id, $course->id, $studentrole->id);
+
+ $this->assertTrue($instance->capabilities()->can_delete_responses($teacher->id));
+ $this->assertFalse($instance->capabilities()->can_delete_responses($student->id));
+ }
+
+ /**
+ * Asserts can_download_responses() returns true for a teacher and false for a student.
+ */
+ public function test_can_download_responses_teacher_vs_student(): void {
+ global $DB;
+ $this->resetAfterTest();
+ $this->setAdminUser();
+
+ $generator = $this->getDataGenerator();
+ $course = $generator->create_course();
+ $instance = $generator->get_plugin_generator('mod_questionnaire')->create_instance(['course' => $course->id]);
+
+ $teacher = $generator->create_user();
+ $student = $generator->create_user();
+ $teacherrole = $DB->get_record('role', ['shortname' => 'teacher'], '*', MUST_EXIST);
+ $studentrole = $DB->get_record('role', ['shortname' => 'student'], '*', MUST_EXIST);
+ $generator->enrol_user($teacher->id, $course->id, $teacherrole->id);
+ $generator->enrol_user($student->id, $course->id, $studentrole->id);
+
+ $this->assertTrue($instance->capabilities()->can_download_responses($teacher->id));
+ $this->assertFalse($instance->capabilities()->can_download_responses($student->id));
+ }
+
+ /**
+ * Asserts can_view_single_response() returns true for a teacher and false for a student.
+ */
+ public function test_can_view_single_response_teacher_vs_student(): void {
+ global $DB;
+ $this->resetAfterTest();
+ $this->setAdminUser();
+
+ $generator = $this->getDataGenerator();
+ $course = $generator->create_course();
+ $instance = $generator->get_plugin_generator('mod_questionnaire')->create_instance(['course' => $course->id]);
+
+ $teacher = $generator->create_user();
+ $student = $generator->create_user();
+ $teacherrole = $DB->get_record('role', ['shortname' => 'teacher'], '*', MUST_EXIST);
+ $studentrole = $DB->get_record('role', ['shortname' => 'student'], '*', MUST_EXIST);
+ $generator->enrol_user($teacher->id, $course->id, $teacherrole->id);
+ $generator->enrol_user($student->id, $course->id, $studentrole->id);
+
+ $this->assertTrue($instance->capabilities()->can_view_single_response($teacher->id));
+ $this->assertFalse($instance->capabilities()->can_view_single_response($student->id));
+ }
+}
diff --git a/tests/custom_completion_test.php b/tests/custom_completion_test.php
index d27a0ce0a..1af6fc254 100644
--- a/tests/custom_completion_test.php
+++ b/tests/custom_completion_test.php
@@ -35,7 +35,7 @@
global $CFG;
require_once($CFG->libdir . '/completionlib.php');
-require_once($CFG->dirroot . '/mod/questionnaire/classes/question/question.php');
+require_once($CFG->dirroot . '/mod/questionnaire/classes/local/question/question.php');
/**
* Class for unit testing mod_questionnaire/custom_completion.
@@ -94,8 +94,8 @@ public function test_get_state(string $rule, int $available, ?bool $submitted, ?
$questionnaire = $generator->create_instance(['course' => $course->id, 'completion' => COMPLETION_TRACKING_AUTOMATIC,
$rule => $available]);
- $questiondata['type_id'] = 1;
- $questiondata['surveyid'] = $questionnaire->sid;
+ $questiondata['typeid'] = 1;
+ $questiondata['surveyid'] = $questionnaire->surveyid();
$questiondata['name'] = 'Q1';
$questiondata['content'] = 'Test content';
$question = $generator->create_question($questionnaire, $questiondata);
@@ -106,7 +106,7 @@ public function test_get_state(string $rule, int $available, ?bool $submitted, ?
}
$this->setUser($student);
- $cm = get_coursemodule_from_instance('questionnaire', $questionnaire->id);
+ $cm = get_coursemodule_from_instance('questionnaire', $questionnaire->id());
$cm = cm_info::create($cm);
$customcompletion = new custom_completion($cm, (int)$student->id);
diff --git a/tests/deletion_question_test.php b/tests/deletion_question_test.php
index 3ea41c7fd..e74997aa5 100644
--- a/tests/deletion_question_test.php
+++ b/tests/deletion_question_test.php
@@ -24,12 +24,12 @@
*/
namespace mod_questionnaire;
+use mod_questionnaire\local\survey\survey;
+
defined('MOODLE_INTERNAL') || die();
global $CFG;
-require_once($CFG->dirroot . '/mod/questionnaire/locallib.php');
-require_once($CFG->dirroot . '/mod/questionnaire/classes/question/question.php');
-require_once($CFG->dirroot . '/mod/questionnaire/questionnaire.class.php');
+require_once($CFG->dirroot . '/mod/questionnaire/classes/local/question/question.php');
/**
* Unit tests for questionnaire_deletion_question_testcase.
@@ -68,7 +68,7 @@ public function setUp(): void {
public function test_restore_deleted_question(): void {
global $DB;
$question = $DB->get_record_select('questionnaire_question', 'name = ?', ['DEMODATE1']);
- questionnaire_restore_deleted_question($question->id, $question->surveyid);
+ survey::restore_deleted_question($question->id, $question->surveyid);
$question = $DB->get_record('questionnaire_question', ['id' => $question->id]);
$this->assertEquals($question->position, 1);
}
@@ -83,7 +83,7 @@ public function test_restore_deleted_question(): void {
public function test_delete_permanently_question(): void {
global $DB;
$question = $DB->get_record_select('questionnaire_question', 'name = ?', ['DEMODATE1']);
- questionnaire_delete_permanently_questions($question->id, $question->surveyid);
+ survey::delete_question_permanently($question->id, $question->surveyid);
$question = $DB->get_record('questionnaire_question', ['id' => $question->id]);
$this->assertEquals($question, false);
}
@@ -99,8 +99,8 @@ public function create_question_by_type($qtype, $qdata): void {
$course = $this->getDataGenerator()->create_course();
$generator = $this->getDataGenerator()->get_plugin_generator('mod_questionnaire');
$questionnaire = $generator->create_instance(['course' => $course->id]);
- $qdata['type_id'] = $qtype;
- $qdata['surveyid'] = $questionnaire->sid;
+ $qdata['typeid'] = $qtype;
+ $qdata['surveyid'] = $questionnaire->surveyid();
$qdata['name'] = isset($qdata['name']) ? $qdata['name'] : 'Q1';
$qdata['content'] = isset($qdata['content']) ? $qdata['content'] : 'Test content';
$qdata['position'] = isset($qdata['position']) ? $qdata['position'] : 1;
diff --git a/tests/form_options_test.php b/tests/form_options_test.php
new file mode 100644
index 000000000..98905561c
--- /dev/null
+++ b/tests/form_options_test.php
@@ -0,0 +1,114 @@
+.
+
+/**
+ * Unit tests for the mod_questionnaire form_options helper.
+ *
+ * @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
+ */
+
+declare(strict_types=1);
+
+namespace mod_questionnaire;
+
+defined('MOODLE_INTERNAL') || die();
+
+global $CFG;
+
+/**
+ * Unit tests for {@see form_options}.
+ *
+ * @covers \mod_questionnaire\form_options
+ */
+final class form_options_test extends \advanced_testcase {
+ public function test_response_frequency_returns_five_options(): void {
+ $this->resetAfterTest();
+ $options = form_options::response_frequency();
+ $this->assertIsArray($options);
+ $this->assertCount(5, $options);
+ }
+
+ public function test_response_frequency_has_expected_keys(): void {
+ $this->resetAfterTest();
+ $options = form_options::response_frequency();
+ $this->assertArrayHasKey(questionnaire::QTYPE_UNLIMITED, $options);
+ $this->assertArrayHasKey(questionnaire::QTYPE_ONCE, $options);
+ $this->assertArrayHasKey(questionnaire::QTYPE_DAILY, $options);
+ $this->assertArrayHasKey(questionnaire::QTYPE_WEEKLY, $options);
+ $this->assertArrayHasKey(questionnaire::QTYPE_MONTHLY, $options);
+ }
+
+ public function test_response_viewer_returns_four_options(): void {
+ $this->resetAfterTest();
+ $options = form_options::response_viewer();
+ $this->assertIsArray($options);
+ $this->assertCount(4, $options);
+ }
+
+ public function test_response_viewer_has_expected_keys(): void {
+ $this->resetAfterTest();
+ $options = form_options::response_viewer();
+ $this->assertArrayHasKey(questionnaire::RESPVIEW_NEVER, $options);
+ $this->assertArrayHasKey(questionnaire::RESPVIEW_WHENANSWERED, $options);
+ $this->assertArrayHasKey(questionnaire::RESPVIEW_WHENCLOSED, $options);
+ $this->assertArrayHasKey(questionnaire::RESPVIEW_ALWAYS, $options);
+ }
+
+ public function test_respondent_type_has_fullname_and_anonymous(): void {
+ $options = form_options::respondent_type();
+ $this->assertArrayHasKey('fullname', $options);
+ $this->assertArrayHasKey('anonymous', $options);
+ }
+
+ public function test_realm_has_three_options(): void {
+ $options = form_options::realm();
+ $this->assertCount(3, $options);
+ $this->assertArrayHasKey('private', $options);
+ $this->assertArrayHasKey('public', $options);
+ $this->assertArrayHasKey('template', $options);
+ }
+
+ public function test_auto_numbering_has_four_options(): void {
+ $options = form_options::auto_numbering();
+ $this->assertCount(4, $options);
+ $this->assertArrayHasKey(0, $options);
+ $this->assertArrayHasKey(3, $options);
+ }
+
+ public function test_editor_returns_expected_keys(): void {
+ $context = \context_system::instance();
+ $options = form_options::editor($context);
+ $this->assertIsArray($options);
+ $this->assertArrayHasKey('subdirs', $options);
+ $this->assertArrayHasKey('maxbytes', $options);
+ $this->assertArrayHasKey('maxfiles', $options);
+ $this->assertArrayHasKey('context', $options);
+ $this->assertArrayHasKey('noclean', $options);
+ $this->assertArrayHasKey('trusttext', $options);
+ $this->assertSame($context, $options['context']);
+ }
+
+ public function test_response_removal_returns_37_entries(): void {
+ $this->resetAfterTest();
+ $options = form_options::response_removal();
+ $this->assertIsArray($options);
+ $this->assertCount(37, $options);
+ $this->assertArrayHasKey(0, $options);
+ }
+}
diff --git a/tests/generator/lib.php b/tests/generator/lib.php
index d311f5894..bf0e5ffeb 100644
--- a/tests/generator/lib.php
+++ b/tests/generator/lib.php
@@ -18,11 +18,10 @@
use mod_questionnaire\generator\question_response,
mod_questionnaire\generator\question_response_rank,
- mod_questionnaire\question\question;
+ mod_questionnaire\local\question\question;
global $CFG;
-require_once($CFG->dirroot . '/mod/questionnaire/locallib.php');
-require_once($CFG->dirroot . '/mod/questionnaire/classes/question/question.php');
+require_once($CFG->dirroot . '/mod/questionnaire/classes/local/question/question.php');
/**
* The mod_questionnaire data generator.
@@ -49,7 +48,7 @@ class mod_questionnaire_generator extends testing_module_generator {
protected $responsecount = 0;
/**
- * @var questionnaire[]
+ * @var \mod_questionnaire\questionnaire[]
*/
protected $questionnaires = [];
@@ -76,7 +75,7 @@ public function questionnaires() {
* Create a questionnaire activity.
* @param array $record Will be changed in this function.
* @param array|null $options
- * @return questionnaire
+ * @return \mod_questionnaire\questionnaire
*/
public function create_instance($record = null, ?array $options = null) {
$record = (object)(array)$record;
@@ -84,8 +83,8 @@ public function create_instance($record = null, ?array $options = null) {
$defaultquestionnairesettings = [
'qtype' => 0,
'respondenttype' => 'fullname',
- 'resp_eligible' => 'all',
- 'resp_view' => 0,
+ 'respeligible' => 'all',
+ 'respview' => 0,
'opendate' => 0,
'closedate' => 0,
'resume' => 0,
@@ -105,9 +104,7 @@ public function create_instance($record = null, ?array $options = null) {
}
$instance = parent::create_instance($record, (array)$options);
- $cm = get_coursemodule_from_instance('questionnaire', $instance->id);
- $course = get_course($cm->course);
- $questionnaire = new \questionnaire($course, $cm, 0, $instance, false);
+ $questionnaire = \mod_questionnaire\questionnaire::from_instanceid($instance->id);
$this->questionnaires[$instance->id] = $questionnaire;
@@ -115,30 +112,30 @@ public function create_instance($record = null, ?array $options = null) {
}
/**
- * Create a survey instance with data from an existing questionnaire object.
- * @param questionnaire $questionnaire
- * @param array $record
- * @return bool|int
+ * Apply additional survey fields to an existing questionnaire's survey row.
+ *
+ * Only fields in survey::update_settings()'s allowlist may appear in $record.
+ *
+ * @param \mod_questionnaire\questionnaire $questionnaire
+ * @param array $record Map of survey field => value (e.g. ['realm' => 'public']).
+ * @return bool|int Survey id on success, false on validation failure.
*/
public function create_content($questionnaire, $record = []) {
- global $DB;
-
- $survey = $DB->get_record('questionnaire_survey', ['id' => $questionnaire->sid], '*', MUST_EXIST);
- foreach ($record as $name => $value) {
- $survey->{$name} = $value;
+ if (empty($record)) {
+ return $questionnaire->surveyid();
}
- return $questionnaire->survey_update($survey);
+ return $questionnaire->survey()->update_settings($record);
}
/**
* Function to create a question.
*
- * @param questionnaire $questionnaire
+ * @param \mod_questionnaire\questionnaire $questionnaire
* @param array|stdClass $record
* @param array|stdClass $data - accompanying data for question - e.g. choices
- * @return \mod_questionnaire\question\question the question object
+ * @return \mod_questionnaire\local\question\question the question object
*/
- public function create_question(questionnaire $questionnaire, $record = null, $data = null) {
+ public function create_question(\mod_questionnaire\questionnaire $questionnaire, $record = null, $data = null) {
global $DB;
// Increment the question count.
@@ -146,7 +143,7 @@ public function create_question(questionnaire $questionnaire, $record = null, $d
$record = (array)$record;
- $record['position'] = count($questionnaire->questions);
+ $record['position'] = count($questionnaire->questions());
if (!isset($record['surveyid'])) {
throw new coding_exception('surveyid must be present in phpunit_util::create_question() $record');
@@ -156,23 +153,23 @@ public function create_question(questionnaire $questionnaire, $record = null, $d
throw new coding_exception('name must be present in phpunit_util::create_question() $record');
}
- if (!isset($record['type_id'])) {
+ if (!isset($record['typeid'])) {
throw new coding_exception('typeid must be present in phpunit_util::create_question() $record');
}
if (!isset($record['content'])) {
- $record['content'] = 'Random ' . $this->type_str($record['type_id']) . ' ' . uniqid();
+ $record['content'] = 'Random ' . $this->type_str($record['typeid']) . ' ' . uniqid();
}
// Get question type.
- $typeid = $record['type_id'];
+ $typeid = $record['typeid'];
if ($typeid === QUESRATE && !isset($record['length'])) {
$record['length'] = 5;
}
if ($typeid !== QUESPAGEBREAK && $typeid !== QUESSECTIONTEXT) {
- $qtype = $DB->get_record('questionnaire_question_type', ['id' => $typeid]);
+ $qtype = $DB->get_record('questionnaire_question_type', ['typeid' => $typeid]);
if (!$qtype) {
throw new coding_exception('Could not find question type with id ' . $typeid);
}
@@ -185,7 +182,7 @@ public function create_question(questionnaire $questionnaire, $record = null, $d
// Add the question.
$record->id = $DB->insert_record('questionnaire_question', $record);
- $question = \mod_questionnaire\question\question::question_builder($record->type_id, $record->id, $record);
+ $question = \mod_questionnaire\local\question\question::question_builder($record->typeid, $record->id, $record);
// Add the question choices if required.
if ($typeid !== QUESPAGEBREAK && $typeid !== QUESSECTIONTEXT) {
@@ -196,7 +193,7 @@ public function create_question(questionnaire $questionnaire, $record = null, $d
}
// Update questionnaire.
- $questionnaire->add_questions();
+ $questionnaire->survey()->add_questions();
return $question;
}
@@ -207,54 +204,48 @@ public function create_question(questionnaire $questionnaire, $record = null, $d
* @param null|int $qtype
* @param array $questiondata
* @param null|array|stdClass $choicedata
- * @return questionnaire
+ * @return \mod_questionnaire\questionnaire
*/
public function create_test_questionnaire($course, $qtype = null, $questiondata = [], $choicedata = null) {
$questionnaire = $this->create_instance(['course' => $course->id]);
- $cm = get_coursemodule_from_instance('questionnaire', $questionnaire->id);
if ($qtype !== null) {
- $questiondata['type_id'] = $qtype;
- $questiondata['surveyid'] = $questionnaire->sid;
+ $questiondata['typeid'] = $qtype;
+ $questiondata['surveyid'] = $questionnaire->surveyid();
$questiondata['name'] = isset($questiondata['name']) ? $questiondata['name'] : 'Q1';
$questiondata['content'] = isset($questiondata['content']) ? $questiondata['content'] : 'Test content';
$this->create_question($questionnaire, $questiondata, $choicedata);
}
- $questionnaire = new \questionnaire($course, $cm, $questionnaire->id, null, true);
- return $questionnaire;
+ return \mod_questionnaire\questionnaire::from_instanceid($questionnaire->id());
}
/**
* Create a reponse to the supplied question.
- * @param questionnaire $questionnaire
+ * @param \mod_questionnaire\questionnaire $questionnaire
* @param question $question
* @param int|array $respval
* @param int $userid
* @param int $section
* @return false|mixed|stdClass
*/
- public function create_question_response($questionnaire, $question, $respval, $userid = 1, $section = 1) {
+ public function create_question_response(
+ \mod_questionnaire\questionnaire $questionnaire,
+ $question,
+ $respval,
+ $userid = 1,
+ $section = 1
+ ) {
global $DB;
+
$currentrid = 0;
if (!is_array($respval)) {
- $respval = ['q' . $question->id => $respval];
+ $respval = ['q' . $question->id() => $respval];
}
- $respdata = (object)(array_merge(['sec' => $section, 'rid' => $currentrid, 'a' => $questionnaire->id], $respval));
- $responseid = $questionnaire->response_insert($respdata, $userid);
- $this->response_commit($questionnaire, $responseid);
- return $DB->get_record('questionnaire_response', ['id' => $responseid]);
- }
+ $respdata = (object)(array_merge(['sec' => $section, 'rid' => $currentrid, 'a' => $questionnaire->id()], $respval));
- /**
- * Need to create a method to access a private questionnaire method.
- * TO DO - may not need this with above "TO DO".
- * @param questionnaire $questionnaire
- * @param int $responseid
- * @return mixed
- */
- private function response_commit($questionnaire, $responseid) {
- $method = new ReflectionMethod('questionnaire', 'response_commit');
- $method->setAccessible(true);
- return $method->invoke($questionnaire, $responseid);
+ $responses = new \mod_questionnaire\local\response\questionnaire_responses($questionnaire);
+ $responseid = $responses->response_insert($respdata, $userid);
+ $responses->response_commit($responseid);
+ return $DB->get_record('questionnaire_response', ['id' => $responseid]);
}
/**
@@ -321,7 +312,7 @@ protected function validate_question($typeid, $data) {
/**
* Add choices to question.
*
- * @param \mod_questionnaire\question\question $question
+ * @param \mod_questionnaire\local\question\question $question
* @param array $data
*/
protected function add_question_choices($question, $data) {
@@ -333,7 +324,7 @@ protected function add_question_choices($question, $data) {
];
}
$record = (object)[
- 'question_id' => $question->id,
+ 'questionid' => $question->id(),
'content' => $content->content,
'value' => $content->value,
];
@@ -448,14 +439,14 @@ public function type_name($qtypeid) {
/**
* Add the response choice.
- * @param \mod_questionnaire\responsetype\response\response $questionresponse
+ * @param \mod_questionnaire\local\response\response $questionresponse
* @param int $responseid
*/
protected function add_response_choice($questionresponse, $responseid) {
global $DB;
$question = $DB->get_record('questionnaire_question', ['id' => $questionresponse->questionid]);
- $qtype = intval($question->type_id);
+ $qtype = intval($question->typeid);
if (is_array($questionresponse->response)) {
foreach ($questionresponse->response as $choice) {
@@ -488,7 +479,7 @@ protected function add_response_choice($questionresponse, $responseid) {
// Lookup the choice id.
$comptext = $DB->sql_compare_text('content');
- $select = 'WHERE question_id = ? AND ' . $comptext . ' = ?';
+ $select = 'WHERE questionid = ? AND ' . $comptext . ' = ?';
$params = [intval($question->id), $choiceval];
$rs = $DB->get_records_sql("SELECT * FROM {questionnaire_quest_choice} $select", $params, 0, 1);
@@ -503,9 +494,9 @@ protected function add_response_choice($questionresponse, $responseid) {
$DB->insert_record(
'questionnaire_response_rank',
[
- 'response_id' => $responseid,
- 'question_id' => $questionresponse->questionid,
- 'choice_id' => $choiceid,
+ 'responseid' => $responseid,
+ 'questionid' => $questionresponse->questionid,
+ 'choiceid' => $choiceid,
'rankvalue' => $questionresponse->response->rankvalue,
]
);
@@ -518,9 +509,9 @@ protected function add_response_choice($questionresponse, $responseid) {
$DB->insert_record(
$instable,
[
- 'response_id' => $responseid,
- 'question_id' => $questionresponse->questionid,
- 'choice_id' => $choiceid,
+ 'responseid' => $responseid,
+ 'questionid' => $questionresponse->questionid,
+ 'choiceid' => $choiceid,
]
);
}
@@ -528,8 +519,8 @@ protected function add_response_choice($questionresponse, $responseid) {
$DB->insert_record(
'questionnaire_response_text',
[
- 'response_id' => $responseid,
- 'question_id' => $questionresponse->questionid,
+ 'responseid' => $responseid,
+ 'questionid' => $questionresponse->questionid,
'response' => $questionresponse->response,
]
);
@@ -612,7 +603,7 @@ public function assign_opts($number = 5) {
/**
* Generate a response.
* @param questionnaire $questionnaire
- * @param \mod_questionnaire\question\question[] $questions
+ * @param \mod_questionnaire\local\question\question[] $questions
* @param int $userid
* @param bool $complete
* @return stdClass
@@ -625,27 +616,27 @@ public function generate_response($questionnaire, $questions, $userid, $complete
$choices = array_values($question->choices);
}
- switch ($question->type_id) {
+ switch ($question->typeid()) {
case QUESTEXT:
- $responses[] = new question_response($question->id, 'Test answer');
+ $responses[] = new question_response($question->id(), 'Test answer');
break;
case QUESESSAY:
$resptext = 'Some header text Some paragraph text
';
- $responses[] = new question_response($question->id, $resptext);
+ $responses[] = new question_response($question->id(), $resptext);
break;
case QUESNUMERIC:
- $responses[] = new question_response($question->id, 83);
+ $responses[] = new question_response($question->id(), 83);
break;
case QUESDATE:
$date = mktime(0, 0, 0, 12, 28, 2017);
$dateformat = get_string('strfdate', 'questionnaire');
$datestr = userdate($date, $dateformat, '1', false);
- $responses[] = new question_response($question->id, $datestr);
+ $responses[] = new question_response($question->id(), $datestr);
break;
case QUESRADIO:
case QUESDROP:
$optidx = count($choices) - 1;
- $responses[] = new question_response($question->id, $choices[$optidx]);
+ $responses[] = new question_response($question->id(), $choices[$optidx]);
break;
case QUESCHECK:
$answers = [];
@@ -656,21 +647,21 @@ public function generate_response($questionnaire, $questions, $userid, $complete
$answers = array_unique($answers);
- $responses[] = new question_response($question->id, $answers);
+ $responses[] = new question_response($question->id(), $answers);
break;
case QUESRATE:
$answers = [];
for ($a = 0; $a < count($choices) - 1; $a++) {
$answers[] = new question_response_rank($choices[$a], (($a % 5) + 1));
}
- $responses[] = new question_response($question->id, $answers);
+ $responses[] = new question_response($question->id(), $answers);
break;
case QUESSLIDER:
- $responses[] = new question_response($question->id, 5);
+ $responses[] = new question_response($question->id(), 5);
break;
}
}
- return $this->create_response($responses, ['questionnaireid' => $questionnaire->id, 'userid' => $userid], $complete);
+ return $this->create_response($responses, ['questionnaireid' => $questionnaire->id(), 'userid' => $userid], $complete);
}
/**
@@ -746,9 +737,9 @@ public function create_and_fully_populate(
$qdg->create_question(
$questionnaire,
[
- 'surveyid' => $questionnaire->sid,
+ 'surveyid' => $questionnaire->surveyid(),
'name' => $qdg->type_name($questiontype),
- 'type_id' => QUESSECTIONTEXT,
+ 'typeid' => QUESSECTIONTEXT,
]
);
// Create questions.
@@ -760,9 +751,9 @@ public function create_and_fully_populate(
$questions[] = $qdg->create_question(
$questionnaire,
[
- 'surveyid' => $questionnaire->sid,
+ 'surveyid' => $questionnaire->surveyid(),
'name' => $qdg->type_name($questiontype) . ' ' . $qname++,
- 'type_id' => $questiontype,
+ 'typeid' => $questiontype,
],
$opts
);
@@ -771,9 +762,9 @@ public function create_and_fully_populate(
$qdg->create_question(
$questionnaire,
[
- 'surveyid' => $questionnaire->sid,
+ 'surveyid' => $questionnaire->surveyid(),
'name' => 'pagebreak ' . $qname++,
- 'type_id' => QUESPAGEBREAK,
+ 'typeid' => QUESPAGEBREAK,
]
);
}
diff --git a/tests/generator_test.php b/tests/generator_test.php
index 455230e88..822f3fe7d 100644
--- a/tests/generator_test.php
+++ b/tests/generator_test.php
@@ -55,18 +55,18 @@ public function test_create_instance(): void {
$questionnaire = $generator->create_instance(['course' => $course->id]);
$this->assertEquals(1, $DB->count_records('questionnaire'));
- $cm = get_coursemodule_from_instance('questionnaire', $questionnaire->id);
- $this->assertEquals($questionnaire->id, $cm->instance);
+ $cm = get_coursemodule_from_instance('questionnaire', $questionnaire->id());
+ $this->assertEquals($questionnaire->id(), $cm->instance);
$this->assertEquals('questionnaire', $cm->modname);
$this->assertEquals($course->id, $cm->course);
$context = \context_module::instance($cm->id);
- $this->assertEquals($questionnaire->cmid, $context->instanceid);
+ $this->assertEquals($questionnaire->coursemodule()->id, $context->instanceid);
- $survey = $DB->get_record('questionnaire_survey', ['id' => $questionnaire->sid]);
- $this->assertEquals($survey->id, $questionnaire->sid);
- $this->assertEquals($questionnaire->name, $survey->name);
- $this->assertEquals($questionnaire->name, $survey->title);
+ $survey = $DB->get_record('questionnaire_survey', ['id' => $questionnaire->surveyid()]);
+ $this->assertEquals($survey->id, $questionnaire->surveyid());
+ $this->assertEquals($questionnaire->name(), $survey->name);
+ $this->assertEquals($questionnaire->name(), $survey->title);
// Should test creating a public questionnaire, template questionnaire and creating one from a template.
@@ -90,20 +90,18 @@ public function test_create_content(): void {
$course = $this->getDataGenerator()->create_course();
$generator = $this->getDataGenerator()->get_plugin_generator('mod_questionnaire');
$questionnaire = $generator->create_instance(['course' => $course->id]);
- $cm = get_coursemodule_from_instance('questionnaire', $questionnaire->id);
- $questionnaire = new \questionnaire($course, $cm, $questionnaire->id, null, false);
$newcontent = [
'title' => 'New title',
'email' => 'test@email.com',
'subtitle' => 'New subtitle',
'info' => 'New info',
- 'thanks_page' => 'http://thankurl.com',
- 'thank_head' => 'New thank header',
- 'thank_body' => 'New thank body',
+ 'thankspage' => 'http://thankurl.com',
+ 'thankhead' => 'New thank header',
+ 'thankbody' => 'New thank body',
];
$sid = $generator->create_content($questionnaire, $newcontent);
- $this->assertEquals($sid, $questionnaire->sid);
+ $this->assertEquals($sid, $questionnaire->surveyid());
$survey = $DB->get_record('questionnaire_survey', ['id' => $sid]);
foreach ($newcontent as $name => $value) {
$this->assertEquals($survey->{$name}, $value);
diff --git a/tests/gradebook_test.php b/tests/gradebook_test.php
new file mode 100644
index 000000000..b7072d334
--- /dev/null
+++ b/tests/gradebook_test.php
@@ -0,0 +1,202 @@
+.
+
+/**
+ * Unit tests for mod_questionnaire\gradebook.
+ *
+ * @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;
+
+/**
+ * Unit tests for mod_questionnaire\gradebook.
+ *
+ * @group mod_questionnaire
+ * @covers \mod_questionnaire\gradebook
+ */
+final class gradebook_test extends \advanced_testcase {
+ /**
+ * Stand up a course + questionnaire instance and return both.
+ *
+ * @param int $grade Grade max for the questionnaire instance.
+ * @return array [course, questionnaire]
+ */
+ private function make_instance(int $grade = 100): array {
+ $generator = $this->getDataGenerator();
+ $course = $generator->create_course();
+ $questionnaire = $generator->get_plugin_generator('mod_questionnaire')
+ ->create_instance(['course' => $course->id, 'grade' => $grade]);
+ return [$course, $questionnaire];
+ }
+
+ /**
+ * Insert a completed response row.
+ *
+ * @param int $questionnaireid
+ * @param int $userid
+ * @param int $grade
+ * @return int
+ */
+ private function insert_response(int $questionnaireid, int $userid, int $grade): int {
+ global $DB;
+ return $DB->insert_record('questionnaire_response', (object)[
+ 'questionnaireid' => $questionnaireid,
+ 'userid' => $userid,
+ 'submitted' => time(),
+ 'complete' => 'y',
+ 'grade' => $grade,
+ ]);
+ }
+
+ /**
+ * get_user_grades() returns rows keyed by response id with rawgrade/dategraded for one user.
+ */
+ public function test_get_user_grades_user_filter(): void {
+ $this->resetAfterTest();
+ $this->setAdminUser();
+ $generator = $this->getDataGenerator();
+
+ [, $questionnaire] = $this->make_instance(100);
+
+ $u1 = $generator->create_user();
+ $u2 = $generator->create_user();
+ $r1 = $this->insert_response($questionnaire->id(), $u1->id, 80);
+ $this->insert_response($questionnaire->id(), $u2->id, 50);
+
+ $rows = gradebook::get_user_grades($questionnaire, $u1->id);
+ $this->assertCount(1, $rows);
+ $row = reset($rows);
+ $this->assertEquals($u1->id, $row->userid);
+ $this->assertEquals(80, $row->rawgrade);
+ }
+
+ /**
+ * get_user_grades() with userid 0 returns rows for every user.
+ */
+ public function test_get_user_grades_all_users(): void {
+ $this->resetAfterTest();
+ $this->setAdminUser();
+ $generator = $this->getDataGenerator();
+
+ [, $questionnaire] = $this->make_instance(100);
+
+ $u1 = $generator->create_user();
+ $u2 = $generator->create_user();
+ $this->insert_response($questionnaire->id(), $u1->id, 80);
+ $this->insert_response($questionnaire->id(), $u2->id, 50);
+
+ $rows = gradebook::get_user_grades($questionnaire);
+ $this->assertCount(2, $rows);
+ }
+
+ /**
+ * get_user_grades() only counts complete responses.
+ */
+ public function test_get_user_grades_skips_incomplete(): void {
+ global $DB;
+ $this->resetAfterTest();
+ $this->setAdminUser();
+ $generator = $this->getDataGenerator();
+
+ [, $questionnaire] = $this->make_instance(100);
+ $user = $generator->create_user();
+ $DB->insert_record('questionnaire_response', (object)[
+ 'questionnaireid' => $questionnaire->id(),
+ 'userid' => $user->id,
+ 'submitted' => time(),
+ 'complete' => 'n',
+ 'grade' => 80,
+ ]);
+
+ $this->assertCount(0, gradebook::get_user_grades($questionnaire));
+ }
+
+ /**
+ * grade_item_update() creates a grade item record for the questionnaire.
+ */
+ public function test_grade_item_update_creates_grade_item(): void {
+ global $DB;
+ $this->resetAfterTest();
+ $this->setAdminUser();
+
+ [, $questionnaire] = $this->make_instance(100);
+
+ $this->assertSame(GRADE_UPDATE_OK, gradebook::grade_item_update($questionnaire));
+
+ $this->assertTrue($DB->record_exists('grade_items', [
+ 'itemmodule' => 'questionnaire',
+ 'iteminstance' => $questionnaire->id(),
+ ]));
+ }
+
+ /**
+ * grade_item_update() with grade = 0 deletes the grade item.
+ */
+ public function test_grade_item_update_with_zero_grade_deletes(): void {
+ global $DB;
+ $this->resetAfterTest();
+ $this->setAdminUser();
+
+ [, $questionnaire] = $this->make_instance(100);
+ // Create the item first.
+ gradebook::grade_item_update($questionnaire);
+ $this->assertTrue($DB->record_exists('grade_items', [
+ 'itemmodule' => 'questionnaire',
+ 'iteminstance' => $questionnaire->id(),
+ ]));
+
+ // Zero-grade instances should remove the grade item.
+ $DB->set_field('questionnaire', 'grade', 0, ['id' => $questionnaire->id()]);
+ $zerorec = $DB->get_record('questionnaire', ['id' => $questionnaire->id()]);
+ $zerorec->cmidnumber = '';
+ gradebook::grade_item_update($zerorec);
+
+ $this->assertFalse($DB->record_exists('grade_items', [
+ 'itemmodule' => 'questionnaire',
+ 'iteminstance' => $questionnaire->id(),
+ ]));
+ }
+
+ /**
+ * update_grades() pushes user grades to the gradebook for a specific questionnaire.
+ */
+ public function test_update_grades_pushes_user_grades(): void {
+ global $DB;
+ $this->resetAfterTest();
+ $this->setAdminUser();
+ $generator = $this->getDataGenerator();
+
+ [, $questionnaire] = $this->make_instance(100);
+ $user = $generator->create_user();
+ $this->insert_response($questionnaire->id(), $user->id, 80);
+
+ gradebook::update_grades($questionnaire, $user->id);
+
+ $itemid = $DB->get_field('grade_items', 'id', [
+ 'itemmodule' => 'questionnaire',
+ 'iteminstance' => $questionnaire->id(),
+ ]);
+ $this->assertNotEmpty($itemid);
+ $this->assertTrue($DB->record_exists('grade_grades', [
+ 'itemid' => $itemid,
+ 'userid' => $user->id,
+ ]));
+ }
+}
diff --git a/tests/instance_admin_test.php b/tests/instance_admin_test.php
new file mode 100644
index 000000000..a0965e7fa
--- /dev/null
+++ b/tests/instance_admin_test.php
@@ -0,0 +1,280 @@
+.
+
+/**
+ * Unit tests for mod_questionnaire\instance_admin.
+ *
+ * @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;
+
+/**
+ * Unit tests for mod_questionnaire\instance_admin.
+ *
+ * @group mod_questionnaire
+ * @covers \mod_questionnaire\instance_admin
+ */
+final class instance_admin_test extends \advanced_testcase {
+ /**
+ * Build the formdata stdClass that mod_form normally produces for create.
+ *
+ * @param int $courseid
+ * @param string $name
+ * @return \stdClass
+ */
+ private function build_add_formdata(int $courseid, string $name = 'New survey'): \stdClass {
+ $placeholder = $this->getDataGenerator()->create_module('questionnaire', [
+ 'course' => $courseid,
+ 'name' => 'Placeholder for cm',
+ ]);
+ // We only use the placeholder to extract a valid coursemodule id.
+ $formdata = new \stdClass();
+ $formdata->coursemodule = $placeholder->coursemodule()->id;
+ $formdata->course = $courseid;
+ $formdata->name = $name;
+ $formdata->intro = '';
+ $formdata->introformat = FORMAT_HTML;
+ $formdata->qtype = 0;
+ $formdata->respondenttype = 'fullname';
+ $formdata->respeligible = 'all';
+ $formdata->respview = 0;
+ $formdata->notifications = 0;
+ $formdata->opendate = 0;
+ $formdata->closedate = 0;
+ $formdata->resume = 0;
+ $formdata->navigate = 0;
+ $formdata->grade = 0;
+ $formdata->sid = 0;
+ $formdata->create = 'new-0';
+ $formdata->autonum = 3;
+ $formdata->progressbar = 0;
+ $formdata->removeafter = 0;
+ $formdata->completionsubmit = 0;
+ return $formdata;
+ }
+
+ /**
+ * add_instance() creates a survey row, a questionnaire row, and links them.
+ */
+ public function test_add_instance_creates_survey_and_questionnaire(): void {
+ global $DB;
+ $this->resetAfterTest();
+ $this->setAdminUser();
+
+ $course = $this->getDataGenerator()->create_course();
+ $formdata = $this->build_add_formdata($course->id, 'My survey');
+
+ $id = instance_admin::add_instance($formdata);
+ $this->assertNotFalse($id);
+
+ $row = $DB->get_record('questionnaire', ['id' => $id], '*', MUST_EXIST);
+ $this->assertSame('My survey', $row->name);
+ $this->assertGreaterThan(0, (int) $row->sid);
+ $this->assertTrue($DB->record_exists('questionnaire_survey', ['id' => $row->sid]));
+ }
+
+ /**
+ * add_instance() with opendate + closedate set creates a calendar event for the instance.
+ */
+ public function test_add_instance_creates_calendar_event(): void {
+ global $DB;
+ $this->resetAfterTest();
+ $this->setAdminUser();
+
+ $course = $this->getDataGenerator()->create_course();
+ $formdata = $this->build_add_formdata($course->id, 'With dates');
+ $formdata->opendate = time() + 3600;
+ $formdata->closedate = time() + (2 * HOURSECS);
+
+ $id = instance_admin::add_instance($formdata);
+
+ $events = $DB->get_records('event', [
+ 'modulename' => 'questionnaire',
+ 'instance' => $id,
+ ]);
+ $this->assertNotEmpty($events);
+ }
+
+ /**
+ * update_instance() renames the questionnaire and syncs the survey realm.
+ */
+ public function test_update_instance_renames(): void {
+ global $DB;
+ $this->resetAfterTest();
+ $this->setAdminUser();
+
+ $course = $this->getDataGenerator()->create_course();
+ $questionnaire = $this->getDataGenerator()
+ ->get_plugin_generator('mod_questionnaire')
+ ->create_instance(['course' => $course->id, 'name' => 'Original']);
+
+ $data = new \stdClass();
+ $data->instance = $questionnaire->id();
+ $data->course = $course->id;
+ $data->coursemodule = $questionnaire->coursemodule()->id;
+ $data->sid = $questionnaire->surveyid();
+ $data->realm = 'private';
+ $data->name = 'Renamed';
+ $data->intro = '';
+ $data->introformat = FORMAT_HTML;
+ $data->qtype = 0;
+ $data->respondenttype = 'fullname';
+ $data->respeligible = 'all';
+ $data->respview = 0;
+ $data->notifications = 0;
+ $data->opendate = 0;
+ $data->closedate = 0;
+ $data->resume = 0;
+ $data->navigate = 0;
+ $data->grade = 0;
+ $data->completionsubmit = 0;
+ $data->autonum = 3;
+ $data->progressbar = 0;
+ $data->removeafter = 0;
+
+ $this->assertTrue(instance_admin::update_instance($data));
+
+ $this->assertSame('Renamed', $DB->get_field('questionnaire', 'name', ['id' => $questionnaire->id()]));
+ }
+
+ /**
+ * delete_instance() removes the row, its responses, and the survey content when owned by the course.
+ */
+ public function test_delete_instance_removes_owned_survey(): void {
+ global $DB;
+ $this->resetAfterTest();
+ $this->setAdminUser();
+
+ $course = $this->getDataGenerator()->create_course();
+ $questionnaire = $this->getDataGenerator()
+ ->get_plugin_generator('mod_questionnaire')
+ ->create_instance(['course' => $course->id]);
+
+ // Insert a stray response.
+ $DB->insert_record('questionnaire_response', (object)[
+ 'questionnaireid' => $questionnaire->id(),
+ 'userid' => 2,
+ 'submitted' => time(),
+ 'complete' => 'y',
+ 'grade' => 0,
+ ]);
+
+ $surveyid = $questionnaire->surveyid();
+ $this->assertTrue(instance_admin::delete_instance($questionnaire->id()));
+
+ $this->assertFalse($DB->record_exists('questionnaire', ['id' => $questionnaire->id()]));
+ $this->assertFalse($DB->record_exists('questionnaire_survey', ['id' => $surveyid]));
+ $this->assertEquals(0, $DB->count_records('questionnaire_response', ['questionnaireid' => $questionnaire->id()]));
+ }
+
+ /**
+ * delete_instance() returns false when the id does not match any row.
+ */
+ public function test_delete_instance_missing_returns_false(): void {
+ $this->resetAfterTest();
+ $this->assertFalse(instance_admin::delete_instance(99999));
+ }
+
+ /**
+ * set_events() replaces existing calendar events for the instance.
+ */
+ public function test_set_events_replaces_existing(): void {
+ global $DB;
+ $this->resetAfterTest();
+ $this->setAdminUser();
+
+ $course = $this->getDataGenerator()->create_course();
+ $questionnaire = $this->getDataGenerator()
+ ->get_plugin_generator('mod_questionnaire')
+ ->create_instance(['course' => $course->id]);
+
+ $data = new \stdClass();
+ $data->id = $questionnaire->id();
+ $data->course = $course->id;
+ $data->name = 'q';
+ $data->opendate = time() + 3600;
+ $data->closedate = time() + (2 * HOURSECS);
+ $data->visible = 1;
+
+ instance_admin::set_events($data);
+ $first = $DB->count_records('event', ['modulename' => 'questionnaire', 'instance' => $questionnaire->id()]);
+ $this->assertGreaterThan(0, $first);
+
+ // Calling again should not double the count — old events get deleted first.
+ instance_admin::set_events($data);
+ $second = $DB->count_records('event', ['modulename' => 'questionnaire', 'instance' => $questionnaire->id()]);
+ $this->assertSame($first, $second);
+ }
+
+ /**
+ * reset_userdata() deletes all responses for course questionnaires when reset is requested.
+ */
+ public function test_reset_userdata_deletes_responses(): void {
+ global $DB;
+ $this->resetAfterTest();
+ $this->setAdminUser();
+
+ $course = $this->getDataGenerator()->create_course();
+ $questionnaire = $this->getDataGenerator()
+ ->get_plugin_generator('mod_questionnaire')
+ ->create_instance(['course' => $course->id]);
+
+ $DB->insert_record('questionnaire_response', (object)[
+ 'questionnaireid' => $questionnaire->id(),
+ 'userid' => 2,
+ 'submitted' => time(),
+ 'complete' => 'y',
+ 'grade' => 0,
+ ]);
+
+ $data = (object)[
+ 'courseid' => $course->id,
+ 'reset_questionnaire' => 1,
+ ];
+ $status = instance_admin::reset_userdata($data);
+ $this->assertNotEmpty($status);
+ $this->assertEquals(0, $DB->count_records('questionnaire_response', ['questionnaireid' => $questionnaire->id()]));
+ }
+
+ /**
+ * reset_userdata() with the flag off does nothing and returns an empty status array.
+ */
+ public function test_reset_userdata_no_op_when_flag_off(): void {
+ global $DB;
+ $this->resetAfterTest();
+ $this->setAdminUser();
+
+ $course = $this->getDataGenerator()->create_course();
+ $questionnaire = $this->getDataGenerator()
+ ->get_plugin_generator('mod_questionnaire')
+ ->create_instance(['course' => $course->id]);
+ $DB->insert_record('questionnaire_response', (object)[
+ 'questionnaireid' => $questionnaire->id(),
+ 'userid' => 2,
+ 'submitted' => time(),
+ 'complete' => 'y',
+ 'grade' => 0,
+ ]);
+
+ $status = instance_admin::reset_userdata((object)['courseid' => $course->id]);
+ $this->assertSame([], $status);
+ $this->assertEquals(1, $DB->count_records('questionnaire_response', ['questionnaireid' => $questionnaire->id()]));
+ }
+}
diff --git a/tests/lib_test.php b/tests/lib_test.php
index 952c52a8e..6da123106 100644
--- a/tests/lib_test.php
+++ b/tests/lib_test.php
@@ -25,13 +25,14 @@
namespace mod_questionnaire;
-use mod_questionnaire\question\question;
+use mod_questionnaire\local\question\question;
+use mod_questionnaire\local\survey\survey;
defined('MOODLE_INTERNAL') || die();
global $CFG;
require_once($CFG->dirroot . '/mod/questionnaire/lib.php');
-require_once($CFG->dirroot . '/mod/questionnaire/classes/question/question.php');
+require_once($CFG->dirroot . '/mod/questionnaire/classes/local/question/question.php');
/**
* Unit tests for questionnaire_lib_testcase.
@@ -94,17 +95,17 @@ public function test_add_instance(): void {
$questdata->introformat = FORMAT_HTML;
$questdata->qtype = 1;
$questdata->respondenttype = 'anonymous';
- $questdata->resp_eligible = 'none';
- $questdata->resp_view = 2;
+ $questdata->respeligible = 'none';
+ $questdata->respview = 2;
$questdata->opendate = 99;
$questdata->closedate = 50;
$questdata->resume = 1;
$questdata->navigate = 1;
$questdata->grade = 100;
- $questdata->sid = 1;
$questdata->timemodified = 3;
$questdata->completionsubmit = 1;
$questdata->autonum = 1;
+ $questdata->create = 'new-0';
// Call add_instance with the data.
$this->assertTrue(questionnaire_add_instance($questdata) > 0);
@@ -127,9 +128,9 @@ public function test_update_instance(): void {
/** @var mod_questionnaire_generator $generator */
$generator = $this->getDataGenerator()->get_plugin_generator('mod_questionnaire');
/** @var questionnaire $questionnaire */
- $questionnaire = $generator->create_instance(['course' => $course->id, 'sid' => 1]);
+ $questionnaire = $generator->create_instance(['course' => $course->id]);
- $qid = $questionnaire->id;
+ $qid = $questionnaire->id();
$this->assertTrue($qid > 0);
// Change all the default values.
@@ -137,17 +138,16 @@ public function test_update_instance(): void {
$qrow = $DB->get_record('questionnaire', ['id' => $qid]);
$qrow->qtype = 1;
$qrow->respondenttype = 'anonymous';
- $qrow->resp_eligible = 'none';
- $qrow->resp_view = 2;
+ $qrow->respeligible = 'none';
+ $qrow->respview = 2;
$qrow->opendate = 99;
$qrow->closedate = 50;
$qrow->resume = 1;
$qrow->navigate = 1;
$qrow->grade = 100;
- $qrow->timemodified = 3;
$qrow->completionsubmit = 1;
$qrow->autonum = 1;
- $qrow->coursemodule = $questionnaire->cm->id;
+ $qrow->coursemodule = $questionnaire->coursemodule()->id;
// Moodle update form passes "instance" instead of "id" to [mod]_update_instance.
$qrow->instance = $qid;
@@ -160,15 +160,14 @@ public function test_update_instance(): void {
$this->assertNotEmpty($questrecord);
$this->assertEquals($qrow->qtype, $questrecord->qtype);
$this->assertEquals($qrow->respondenttype, $questrecord->respondenttype);
- $this->assertEquals($qrow->resp_eligible, $questrecord->resp_eligible);
- $this->assertEquals($qrow->resp_view, $questrecord->resp_view);
+ $this->assertEquals($qrow->respeligible, $questrecord->respeligible);
+ $this->assertEquals($qrow->respview, $questrecord->respview);
$this->assertEquals($qrow->opendate, $questrecord->opendate);
$this->assertEquals($qrow->closedate, $questrecord->closedate);
$this->assertEquals($qrow->resume, $questrecord->resume);
$this->assertEquals($qrow->navigate, $questrecord->navigate);
$this->assertEquals($qrow->grade, $questrecord->grade);
$this->assertEquals($qrow->sid, $questrecord->sid);
- $this->assertEquals($qrow->timemodified, $questrecord->timemodified);
$this->assertEquals($qrow->completionsubmit, $questrecord->completionsubmit);
$this->assertEquals($qrow->autonum, $questrecord->autonum);
}
@@ -196,22 +195,23 @@ public function test_delete_instance(): void {
$generator = $this->getDataGenerator()->get_plugin_generator('mod_questionnaire');
$questionnaire = $generator->create_test_questionnaire($course, QUESYESNO, $questiondata);
- $question = reset($questionnaire->questions);
+ $questions = $questionnaire->questions();
+ $question = reset($questions);
// Add a response for the question.
$response = $generator->create_question_response($questionnaire, $question, 'y');
// Get records for database deletion confirmation.
- $survey = $DB->get_record('questionnaire_survey', ['id' => $questionnaire->sid]);
+ $survey = $DB->get_record('questionnaire_survey', ['id' => $questionnaire->surveyid()]);
// Now delete it all.
- $this->assertTrue(questionnaire_delete_instance($questionnaire->id));
- $this->assertEmpty($DB->get_record('questionnaire', ['id' => $questionnaire->id]));
- $this->assertEmpty($DB->get_record('questionnaire_survey', ['id' => $questionnaire->sid]));
+ $this->assertTrue(questionnaire_delete_instance($questionnaire->id()));
+ $this->assertEmpty($DB->get_record('questionnaire', ['id' => $questionnaire->id()]));
+ $this->assertEmpty($DB->get_record('questionnaire_survey', ['id' => $questionnaire->surveyid()]));
$this->assertEmpty($DB->get_records('questionnaire_question', ['surveyid' => $survey->id]));
- $this->assertEmpty($DB->get_records('questionnaire_response', ['questionnaireid' => $questionnaire->id]));
- $this->assertEmpty($DB->get_records('questionnaire_response_bool', ['response_id' => $response->id]));
- $this->assertEmpty($DB->get_records('event', ["modulename" => 'questionnaire', "instance" => $questionnaire->id]));
+ $this->assertEmpty($DB->get_records('questionnaire_response', ['questionnaireid' => $questionnaire->id()]));
+ $this->assertEmpty($DB->get_records('questionnaire_response_bool', ['responseid' => $response->id]));
+ $this->assertEmpty($DB->get_records('event', ["modulename" => 'questionnaire', "instance" => $questionnaire->id()]));
}
/**
@@ -237,7 +237,8 @@ public function test_questionnaire_user_outline(): void {
$this->assertEquals(get_string("noresponses", "questionnaire"), $outline->info);
// Test for a user with one response.
- $generator->create_question_response($questionnaire, reset($questionnaire->questions), 'y', $user->id);
+ $questions = $questionnaire->questions();
+ $generator->create_question_response($questionnaire, reset($questions), 'y', $user->id);
$outline = questionnaire_user_outline($course, $user, null, $questionnaire);
$this->assertEquals('1 ' . get_string("response", "questionnaire"), $outline->info);
}
@@ -338,8 +339,8 @@ public function test_questionnaire_grade_item_update(): void {
$course = $this->getDataGenerator()->create_course();
$generator = $this->getDataGenerator()->get_plugin_generator('mod_questionnaire');
$questionnaire = $generator->create_test_questionnaire($course);
- $questionnaire->cmidnumber = $questionnaire->cm->idnumber;
- $questionnaire->courseid = $questionnaire->course->id;
+ $questionnaire->cmidnumber = $questionnaire->coursemodule()->idnumber;
+ $questionnaire->courseid = $questionnaire->course()->id;
$this->assertEquals(GRADE_UPDATE_OK, questionnaire_grade_item_update($questionnaire));
}
@@ -348,7 +349,7 @@ public function test_questionnaire_grade_item_update(): void {
*
* @return void
*
- * @covers \questionnaire_restore_deleted_question
+ * @covers \mod_questionnaire\questionnaire::restore_deleted_question
*/
public function test_questionnaire_restore_deleted_question(): void {
global $DB;
@@ -357,28 +358,28 @@ public function test_questionnaire_restore_deleted_question(): void {
$course = $this->getDataGenerator()->create_course();
$generator = $this->getDataGenerator()->get_plugin_generator('mod_questionnaire');
$questionnaire = $generator->create_test_questionnaire($course);
- $sid = $questionnaire->sid;
+ $sid = $questionnaire->surveyid();
// Add three questions.
$q1 = $DB->insert_record('questionnaire_question', [
- 'surveyid' => $sid, 'type_id' => 1, 'position' => 1, 'deleted' => null, 'content' => 'Q1']);
+ 'surveyid' => $sid, 'typeid' => 1, 'position' => 1, 'deleted' => null, 'content' => 'Q1']);
$q2 = $DB->insert_record('questionnaire_question', [
- 'surveyid' => $sid, 'type_id' => 1, 'position' => 2, 'deleted' => null, 'content' => 'Q2']);
+ 'surveyid' => $sid, 'typeid' => 1, 'position' => 2, 'deleted' => null, 'content' => 'Q2']);
$q3 = $DB->insert_record('questionnaire_question', [
- 'surveyid' => $sid, 'type_id' => 1, 'position' => 3, 'deleted' => null, 'content' => 'Q3']);
+ 'surveyid' => $sid, 'typeid' => 1, 'position' => 3, 'deleted' => null, 'content' => 'Q3']);
// Delete Q2 and Q3.
$DB->set_field('questionnaire_question', 'deleted', 1234567890, ['id' => $q2]);
$DB->set_field('questionnaire_question', 'deleted', 1234567891, ['id' => $q3]);
// Restore Q2.
- questionnaire_restore_deleted_question($q2, $sid);
+ survey::restore_deleted_question($q2, $sid);
$restoredq2 = $DB->get_record('questionnaire_question', ['id' => $q2]);
$this->assertNull($restoredq2->deleted);
$this->assertEquals(2, $restoredq2->position); // Should be after Q1.
// Restore Q3.
- questionnaire_restore_deleted_question($q3, $sid);
+ survey::restore_deleted_question($q3, $sid);
$restoredq3 = $DB->get_record('questionnaire_question', ['id' => $q3]);
$this->assertNull($restoredq3->deleted);
$this->assertEquals(3, $restoredq3->position); // Should be after Q2.
diff --git a/tests/local/db/choice_record_test.php b/tests/local/db/choice_record_test.php
new file mode 100644
index 000000000..0435b37fa
--- /dev/null
+++ b/tests/local/db/choice_record_test.php
@@ -0,0 +1,100 @@
+.
+
+/**
+ * Unit tests for mod_questionnaire\local\db\choice_record.
+ *
+ * @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\local\db;
+
+/**
+ * Unit tests for mod_questionnaire\local\db\choice_record.
+ *
+ * @group mod_questionnaire
+ * @covers \mod_questionnaire\local\db\choice_record
+ */
+final class choice_record_test extends \advanced_testcase {
+ /**
+ * Insert a choice row directly via $DB and return the new id.
+ *
+ * @param int $questionid
+ * @param string $content
+ * @return int
+ */
+ private function make_choice(int $questionid, string $content = 'option'): int {
+ global $DB;
+ return $DB->insert_record('questionnaire_quest_choice', (object)[
+ 'questionid' => $questionid,
+ 'content' => $content,
+ 'value' => null,
+ ]);
+ }
+
+ /**
+ * get_for_question() returns every choice for a question.
+ */
+ public function test_get_for_question_returns_choices(): void {
+ $this->resetAfterTest();
+ $qid = 7001;
+ $c1 = $this->make_choice($qid, 'a');
+ $c2 = $this->make_choice($qid, 'b');
+
+ $records = choice_record::get_for_question($qid);
+ $this->assertCount(2, $records);
+ $ids = array_map(fn($r) => $r->get('id'), $records);
+ $this->assertContains($c1, $ids);
+ $this->assertContains($c2, $ids);
+ }
+
+ /**
+ * get_for_question() returns an empty array when the question has no choices.
+ */
+ public function test_get_for_question_empty(): void {
+ $this->resetAfterTest();
+ $this->assertSame([], choice_record::get_for_question(99999));
+ }
+
+ /**
+ * delete_for_question() removes only rows belonging to the given question.
+ */
+ public function test_delete_for_question_scopes_to_question(): void {
+ global $DB;
+ $this->resetAfterTest();
+ $qid = 7002;
+ $otherqid = 7003;
+ $this->make_choice($qid);
+ $this->make_choice($qid);
+ $this->make_choice($otherqid);
+
+ $this->assertTrue(choice_record::delete_for_question($qid));
+
+ $this->assertEquals(0, $DB->count_records('questionnaire_quest_choice', ['questionid' => $qid]));
+ $this->assertEquals(1, $DB->count_records('questionnaire_quest_choice', ['questionid' => $otherqid]));
+ }
+
+ /**
+ * delete_for_question() is a no-op when there are no choices for the question.
+ */
+ public function test_delete_for_question_no_op_when_empty(): void {
+ $this->resetAfterTest();
+ $this->assertTrue(choice_record::delete_for_question(99999));
+ }
+}
diff --git a/tests/local/db/dependency_record_test.php b/tests/local/db/dependency_record_test.php
new file mode 100644
index 000000000..70b27e3f3
--- /dev/null
+++ b/tests/local/db/dependency_record_test.php
@@ -0,0 +1,127 @@
+.
+
+/**
+ * Unit tests for mod_questionnaire\local\db\dependency_record.
+ *
+ * @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\local\db;
+
+/**
+ * Unit tests for mod_questionnaire\local\db\dependency_record.
+ *
+ * @group mod_questionnaire
+ * @covers \mod_questionnaire\local\db\dependency_record
+ */
+final class dependency_record_test extends \advanced_testcase {
+ /**
+ * Insert a dependency row via $DB and return its id.
+ *
+ * @param int $surveyid
+ * @param int $questionid
+ * @param int $dependquestionid
+ * @return int
+ */
+ private function make_dep(int $surveyid, int $questionid, int $dependquestionid): int {
+ global $DB;
+ return $DB->insert_record('questionnaire_dependency', (object)[
+ 'questionid' => $questionid,
+ 'surveyid' => $surveyid,
+ 'dependquestionid' => $dependquestionid,
+ 'dependchoiceid' => 0,
+ 'dependlogic' => 1,
+ 'dependandor' => 'and',
+ ]);
+ }
+
+ /**
+ * get_for_question() returns every dependency rooted at the given question.
+ */
+ public function test_get_for_question(): void {
+ $this->resetAfterTest();
+ $sid = 80;
+ $qid = 81;
+ $this->make_dep($sid, $qid, 90);
+ $this->make_dep($sid, $qid, 91);
+ $this->make_dep($sid, 999, 90); // Unrelated question.
+
+ $records = dependency_record::get_for_question($qid);
+ $this->assertCount(2, $records);
+ }
+
+ /**
+ * get_for_survey() returns every dependency belonging to the survey.
+ */
+ public function test_get_for_survey(): void {
+ $this->resetAfterTest();
+ $sid = 82;
+ $othersid = 83;
+ $this->make_dep($sid, 100, 200);
+ $this->make_dep($sid, 101, 200);
+ $this->make_dep($othersid, 110, 210);
+
+ $records = dependency_record::get_for_survey($sid);
+ $this->assertCount(2, $records);
+ }
+
+ /**
+ * delete_for_survey() removes only the survey's dependencies.
+ */
+ public function test_delete_for_survey_scoped(): void {
+ global $DB;
+ $this->resetAfterTest();
+ $sid = 84;
+ $othersid = 85;
+ $this->make_dep($sid, 100, 200);
+ $this->make_dep($sid, 101, 201);
+ $keep = $this->make_dep($othersid, 110, 210);
+
+ $this->assertTrue(dependency_record::delete_for_survey($sid));
+
+ $this->assertEquals(0, $DB->count_records('questionnaire_dependency', ['surveyid' => $sid]));
+ $this->assertTrue($DB->record_exists('questionnaire_dependency', ['id' => $keep]));
+ }
+
+ /**
+ * delete_for_question() removes dependencies on both sides of the relationship —
+ * those where the question depends on others, and those where others depend on it.
+ */
+ public function test_delete_for_question_removes_both_sides(): void {
+ global $DB;
+ $this->resetAfterTest();
+ $sid = 86;
+ $qid = 300;
+ $other = 301;
+
+ // Qid depends on other.
+ $this->make_dep($sid, $qid, $other);
+ // Other depends on qid (qid is the depend target).
+ $this->make_dep($sid, $other, $qid);
+ // Unrelated.
+ $kept = $this->make_dep($sid, 999, 998);
+
+ $this->assertTrue(dependency_record::delete_for_question($qid));
+
+ $this->assertEquals(0, $DB->count_records('questionnaire_dependency', ['questionid' => $qid]));
+ $this->assertEquals(0, $DB->count_records('questionnaire_dependency', ['dependquestionid' => $qid]));
+ $this->assertTrue($DB->record_exists('questionnaire_dependency', ['id' => $kept]));
+ }
+}
diff --git a/tests/local/db/feedback_record_test.php b/tests/local/db/feedback_record_test.php
new file mode 100644
index 000000000..26868a5bd
--- /dev/null
+++ b/tests/local/db/feedback_record_test.php
@@ -0,0 +1,174 @@
+.
+
+/**
+ * Unit tests for mod_questionnaire\local\db\feedback_record.
+ *
+ * @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\local\db;
+
+/**
+ * Unit tests for mod_questionnaire\local\db\feedback_record.
+ *
+ * @group mod_questionnaire
+ * @covers \mod_questionnaire\local\db\feedback_record
+ */
+final class feedback_record_test extends \advanced_testcase {
+ /**
+ * Insert a feedback row directly via $DB and return its id.
+ *
+ * @param int $sectionid
+ * @param float $minscore
+ * @param float $maxscore
+ * @return int
+ */
+ private function make_feedback(int $sectionid, float $minscore = 0.0, float $maxscore = 100.0): int {
+ global $DB;
+ return $DB->insert_record('questionnaire_feedback', (object)[
+ 'sectionid' => $sectionid,
+ 'feedbacklabel' => '',
+ 'feedbacktext' => '',
+ 'feedbacktextformat' => FORMAT_HTML,
+ 'minscore' => $minscore,
+ 'maxscore' => $maxscore,
+ ]);
+ }
+
+ /**
+ * get_for_section() returns every feedback row for the section.
+ */
+ public function test_get_for_section(): void {
+ $this->resetAfterTest();
+ $sid = 901;
+ $this->make_feedback($sid, 0, 50);
+ $this->make_feedback($sid, 50, 100);
+ $this->make_feedback(902, 0, 100); // Different section.
+
+ $this->assertCount(2, feedback_record::get_for_section($sid));
+ $this->assertCount(0, feedback_record::get_for_section(999));
+ }
+
+ /**
+ * get_for_section() honors the sort + order arguments.
+ */
+ public function test_get_for_section_sorted(): void {
+ $this->resetAfterTest();
+ $sid = 903;
+ $low = $this->make_feedback($sid, 0, 50);
+ $high = $this->make_feedback($sid, 75, 100);
+ $mid = $this->make_feedback($sid, 50, 75);
+
+ $sorted = array_values(feedback_record::get_for_section($sid, 'minscore', 'DESC'));
+ $this->assertCount(3, $sorted);
+ $this->assertSame($high, $sorted[0]->get('id'));
+ $this->assertSame($mid, $sorted[1]->get('id'));
+ $this->assertSame($low, $sorted[2]->get('id'));
+ }
+
+ /**
+ * delete_for_section() removes only feedback rows for the given section.
+ */
+ public function test_delete_for_section_scoped(): void {
+ global $DB;
+ $this->resetAfterTest();
+ $target = 904;
+ $other = 905;
+ $this->make_feedback($target);
+ $this->make_feedback($target);
+ $keep = $this->make_feedback($other);
+
+ $this->assertTrue(feedback_record::delete_for_section($target));
+
+ $this->assertEquals(0, $DB->count_records('questionnaire_feedback', ['sectionid' => $target]));
+ $this->assertTrue($DB->record_exists('questionnaire_feedback', ['id' => $keep]));
+ }
+
+ /**
+ * get_records_for_section() returns stdClass rows scoped to the section
+ * (matches the legacy $DB->get_records shape used by the scoreboard math).
+ */
+ public function test_get_records_for_section_returns_stdclass(): void {
+ $this->resetAfterTest();
+ $target = 906;
+ $this->make_feedback($target, 0, 50);
+ $this->make_feedback($target, 50, 100);
+ $this->make_feedback(907, 0, 100);
+
+ $rows = feedback_record::get_records_for_section($target);
+
+ $this->assertCount(2, $rows);
+ foreach ($rows as $row) {
+ $this->assertInstanceOf(\stdClass::class, $row);
+ $this->assertSame($target, (int) $row->sectionid);
+ }
+ }
+
+ /**
+ * find_for_score() returns the band whose minscore ≤ score < maxscore.
+ */
+ public function test_find_for_score_returns_matching_band(): void {
+ $this->resetAfterTest();
+ $sid = 908;
+ $low = $this->make_feedback($sid, 0, 33);
+ $mid = $this->make_feedback($sid, 33, 66);
+ $high = $this->make_feedback($sid, 66, 100);
+
+ $match = feedback_record::find_for_score($sid, 50.0);
+ $this->assertNotNull($match);
+ $this->assertSame($mid, (int) $match->id);
+
+ // Boundary: minscore is inclusive, maxscore is exclusive.
+ $atboundary = feedback_record::find_for_score($sid, 33.0);
+ $this->assertSame($mid, (int) $atboundary->id);
+
+ // Low-end inclusive.
+ $atzero = feedback_record::find_for_score($sid, 0.0);
+ $this->assertSame($low, (int) $atzero->id);
+ $this->assertNotSame($high, (int) $atzero->id);
+ }
+
+ /**
+ * find_for_score() returns null when no band covers the score.
+ */
+ public function test_find_for_score_returns_null_when_no_band(): void {
+ $this->resetAfterTest();
+ $sid = 909;
+ $this->make_feedback($sid, 0, 50);
+
+ $this->assertNull(feedback_record::find_for_score($sid, 75.0));
+ }
+
+ /**
+ * find_for_score() honors the optional field list parameter.
+ */
+ public function test_find_for_score_with_field_subset(): void {
+ $this->resetAfterTest();
+ $sid = 910;
+ $this->make_feedback($sid, 0, 100);
+
+ $row = feedback_record::find_for_score($sid, 50.0, 'id,minscore');
+
+ $this->assertNotNull($row);
+ $this->assertObjectHasProperty('id', $row);
+ $this->assertObjectHasProperty('minscore', $row);
+ $this->assertObjectNotHasProperty('feedbacktext', $row);
+ }
+}
diff --git a/tests/local/db/feedback_section_record_test.php b/tests/local/db/feedback_section_record_test.php
new file mode 100644
index 000000000..4cb8f849a
--- /dev/null
+++ b/tests/local/db/feedback_section_record_test.php
@@ -0,0 +1,168 @@
+.
+
+/**
+ * Unit tests for mod_questionnaire\local\db\feedback_section_record.
+ *
+ * @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\local\db;
+
+/**
+ * Unit tests for mod_questionnaire\local\db\feedback_section_record.
+ *
+ * @group mod_questionnaire
+ * @covers \mod_questionnaire\local\db\feedback_section_record
+ */
+final class feedback_section_record_test extends \advanced_testcase {
+ /**
+ * Insert a feedback-section row directly via $DB and return its id.
+ *
+ * @param int $surveyid
+ * @param int $sectionnum
+ * @return int
+ */
+ private function make_section(int $surveyid, int $sectionnum): int {
+ global $DB;
+ return $DB->insert_record('questionnaire_fb_sections', (object)[
+ 'surveyid' => $surveyid,
+ 'section' => $sectionnum,
+ 'scorecalculation' => '',
+ 'sectionlabel' => 'L' . $sectionnum,
+ 'sectionheading' => '',
+ 'sectionheadingformat' => FORMAT_HTML,
+ ]);
+ }
+
+ /**
+ * get_for_survey() returns every feedback section for the survey.
+ */
+ public function test_get_for_survey(): void {
+ $this->resetAfterTest();
+ $sid = 401;
+ $this->make_section($sid, 1);
+ $this->make_section($sid, 2);
+ $this->make_section(402, 1); // Different survey.
+
+ $this->assertCount(2, feedback_section_record::get_for_survey($sid));
+ $this->assertCount(0, feedback_section_record::get_for_survey(999));
+ }
+
+ /**
+ * get_for_survey() honors the sort + order arguments.
+ */
+ public function test_get_for_survey_sorted(): void {
+ $this->resetAfterTest();
+ $sid = 403;
+ $third = $this->make_section($sid, 3);
+ $first = $this->make_section($sid, 1);
+ $second = $this->make_section($sid, 2);
+
+ $sorted = array_values(feedback_section_record::get_for_survey($sid, 'section'));
+ $this->assertCount(3, $sorted);
+ $this->assertSame($first, $sorted[0]->get('id'));
+ $this->assertSame($second, $sorted[1]->get('id'));
+ $this->assertSame($third, $sorted[2]->get('id'));
+ }
+
+ /**
+ * max_section_for_survey() returns the highest section number, or 0 if empty.
+ */
+ public function test_max_section_for_survey(): void {
+ $this->resetAfterTest();
+ $sid = 404;
+
+ $this->assertSame(0, feedback_section_record::max_section_for_survey($sid));
+
+ $this->make_section($sid, 1);
+ $this->make_section($sid, 5);
+ $this->make_section($sid, 3);
+
+ $this->assertSame(5, feedback_section_record::max_section_for_survey($sid));
+ // Other surveys are not considered.
+ $this->assertSame(0, feedback_section_record::max_section_for_survey(999));
+ }
+
+ /**
+ * delete_for_survey() removes only the survey's section rows.
+ */
+ public function test_delete_for_survey_scoped(): void {
+ global $DB;
+ $this->resetAfterTest();
+ $sid = 405;
+ $other = 406;
+ $this->make_section($sid, 1);
+ $this->make_section($sid, 2);
+ $keep = $this->make_section($other, 1);
+
+ $this->assertTrue(feedback_section_record::delete_for_survey($sid));
+
+ $this->assertEquals(0, $DB->count_records('questionnaire_fb_sections', ['surveyid' => $sid]));
+ $this->assertTrue($DB->record_exists('questionnaire_fb_sections', ['id' => $keep]));
+ }
+
+ /**
+ * min_section_for_survey() returns the lowest section number, or 0 if empty.
+ */
+ public function test_min_section_for_survey(): void {
+ $this->resetAfterTest();
+ $sid = 407;
+
+ $this->assertSame(0, feedback_section_record::min_section_for_survey($sid));
+
+ $this->make_section($sid, 4);
+ $this->make_section($sid, 2);
+ $this->make_section($sid, 7);
+
+ $this->assertSame(2, feedback_section_record::min_section_for_survey($sid));
+ $this->assertSame(0, feedback_section_record::min_section_for_survey(999));
+ }
+
+ /**
+ * get_numbered_section_records_for_survey() returns stdClass rows whose section
+ * number is not null, scoped to the survey.
+ */
+ public function test_get_numbered_section_records_for_survey_filters_nulls(): void {
+ global $DB;
+ $this->resetAfterTest();
+ $sid = 408;
+ $this->make_section($sid, 1);
+ $this->make_section($sid, 2);
+ // Insert a section with null section number — should be excluded.
+ $DB->insert_record('questionnaire_fb_sections', (object)[
+ 'surveyid' => $sid,
+ 'section' => null,
+ 'scorecalculation' => '',
+ 'sectionlabel' => 'orphan',
+ 'sectionheading' => '',
+ 'sectionheadingformat' => FORMAT_HTML,
+ ]);
+ // And one for a different survey, which should not appear.
+ $this->make_section(409, 1);
+
+ $rows = feedback_section_record::get_numbered_section_records_for_survey($sid);
+
+ $this->assertCount(2, $rows);
+ foreach ($rows as $row) {
+ $this->assertInstanceOf(\stdClass::class, $row);
+ $this->assertNotNull($row->section);
+ }
+ }
+}
diff --git a/tests/local/db/question_record_test.php b/tests/local/db/question_record_test.php
new file mode 100644
index 000000000..4e4ba8b45
--- /dev/null
+++ b/tests/local/db/question_record_test.php
@@ -0,0 +1,314 @@
+.
+
+/**
+ * Unit tests for mod_questionnaire\local\db\question_record.
+ *
+ * @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\local\db;
+
+use mod_questionnaire\local\question_type;
+
+/**
+ * Unit tests for mod_questionnaire\local\db\question_record.
+ *
+ * @group mod_questionnaire
+ * @covers \mod_questionnaire\local\db\question_record
+ */
+final class question_record_test extends \advanced_testcase {
+ /**
+ * Insert a question row via $DB and return its id.
+ *
+ * @param int $surveyid
+ * @param int $position
+ * @param int $typeid
+ * @param int|null $deleted
+ * @return int
+ */
+ private function make_question(int $surveyid, int $position, int $typeid = 1, ?int $deleted = null): int {
+ global $DB;
+ return $DB->insert_record('questionnaire_question', (object)[
+ 'surveyid' => $surveyid,
+ 'name' => 'Q' . $position,
+ 'typeid' => $typeid,
+ 'length' => 0,
+ 'precise' => 0,
+ 'position' => $position,
+ 'content' => 'content ' . $position,
+ 'required' => 'n',
+ 'deleted' => $deleted,
+ ]);
+ }
+
+ /**
+ * get_active_for_survey() returns only non-deleted questions, ordered by position, keyed by id.
+ */
+ public function test_get_active_for_survey(): void {
+ $this->resetAfterTest();
+ $sid = 8001;
+ $third = $this->make_question($sid, 3);
+ $first = $this->make_question($sid, 1);
+ $second = $this->make_question($sid, 2);
+ $this->make_question($sid, 4, 1, time()); // Soft-deleted — excluded.
+
+ $records = question_record::get_active_for_survey($sid);
+ $this->assertCount(3, $records);
+
+ $ordered = array_values($records);
+ $this->assertSame($first, $ordered[0]->get('id'));
+ $this->assertSame($second, $ordered[1]->get('id'));
+ $this->assertSame($third, $ordered[2]->get('id'));
+ }
+
+ /**
+ * get_for_survey() returns every question for the survey, including soft-deleted ones.
+ */
+ public function test_get_for_survey_includes_deleted(): void {
+ $this->resetAfterTest();
+ $sid = 8002;
+ $this->make_question($sid, 1);
+ $this->make_question($sid, 2, 1, time()); // Soft-deleted.
+ $this->make_question(8003, 1); // Different survey.
+
+ $this->assertCount(2, question_record::get_for_survey($sid));
+ $this->assertCount(0, question_record::get_for_survey(99999));
+ }
+
+ /**
+ * get_for_type() returns questions of the given type, optionally filtered by survey.
+ */
+ public function test_get_for_type(): void {
+ $this->resetAfterTest();
+ $sida = 8004;
+ $sidb = 8005;
+ $this->make_question($sida, 1, question_type::QUESRATE);
+ $this->make_question($sida, 2, question_type::QUESRATE);
+ $this->make_question($sida, 3, question_type::QUESTEXT);
+ $this->make_question($sidb, 1, question_type::QUESRATE);
+
+ $this->assertCount(3, question_record::get_for_type(question_type::QUESRATE));
+ $this->assertCount(2, question_record::get_for_type(question_type::QUESRATE, $sida));
+ $this->assertCount(1, question_record::get_for_type(question_type::QUESRATE, $sidb));
+ $this->assertCount(0, question_record::get_for_type(question_type::QUESYESNO));
+ }
+
+ /**
+ * get_deleted_for_survey() returns soft-deleted questions, excluding pagebreaks,
+ * ordered by deletion time DESC.
+ */
+ public function test_get_deleted_for_survey(): void {
+ $this->resetAfterTest();
+ $sid = 8006;
+ $this->make_question($sid, 1); // Active — excluded.
+ $older = $this->make_question($sid, 2, 1, time() - 3600);
+ $newer = $this->make_question($sid, 3, 1, time());
+ $this->make_question($sid, 4, question_type::QUESPAGEBREAK, time()); // Pagebreak — excluded.
+
+ $records = question_record::get_deleted_for_survey($sid);
+ $this->assertCount(2, $records);
+ $ordered = array_values($records);
+ $this->assertSame($newer, $ordered[0]->get('id'));
+ $this->assertSame($older, $ordered[1]->get('id'));
+ }
+
+ /**
+ * get_soft_deleted() returns the row only when it's actually soft-deleted, otherwise null.
+ */
+ public function test_get_soft_deleted(): void {
+ $this->resetAfterTest();
+ $sid = 8007;
+ $active = $this->make_question($sid, 1);
+ $deleted = $this->make_question($sid, 2, 1, time());
+
+ $this->assertNull(question_record::get_soft_deleted($active, $sid));
+ $this->assertNull(question_record::get_soft_deleted($deleted, 99999)); // Wrong survey.
+
+ $found = question_record::get_soft_deleted($deleted, $sid);
+ $this->assertNotNull($found);
+ $this->assertSame($deleted, $found->get('id'));
+ }
+
+ /**
+ * max_active_position_for_survey() returns the highest position of active questions, or 0.
+ */
+ public function test_max_active_position_for_survey(): void {
+ $this->resetAfterTest();
+ $sid = 8008;
+
+ $this->assertSame(0, question_record::max_active_position_for_survey($sid));
+
+ $this->make_question($sid, 1);
+ $this->make_question($sid, 5);
+ $this->make_question($sid, 3);
+ $this->make_question($sid, 9, 1, time()); // Soft-deleted — excluded.
+
+ $this->assertSame(5, question_record::max_active_position_for_survey($sid));
+ $this->assertSame(0, question_record::max_active_position_for_survey(99999));
+ }
+
+ /**
+ * count_active_before_position() counts only matching-type, active questions strictly below the position.
+ */
+ public function test_count_active_before_position(): void {
+ $this->resetAfterTest();
+ $sid = 8108;
+ $pb = QUESPAGEBREAK;
+
+ $this->make_question($sid, 1, $pb);
+ $this->make_question($sid, 3, $pb);
+ $this->make_question($sid, 5, $pb);
+ $this->make_question($sid, 4, 1); // Wrong typeid — excluded.
+ $this->make_question($sid, 2, $pb, time()); // Soft-deleted — excluded.
+
+ $this->assertSame(2, question_record::count_active_before_position($sid, $pb, 5));
+ $this->assertSame(3, question_record::count_active_before_position($sid, $pb, 99));
+ $this->assertSame(0, question_record::count_active_before_position($sid, $pb, 1));
+ $this->assertSame(0, question_record::count_active_before_position(99999, $pb, 99));
+ }
+
+ /**
+ * update_position() sets the position field on the specified row.
+ */
+ public function test_update_position(): void {
+ global $DB;
+ $this->resetAfterTest();
+ $sid = 8009;
+ $qid = $this->make_question($sid, 1);
+
+ $this->assertTrue(question_record::update_position($qid, 7));
+
+ $this->assertSame(7, (int) $DB->get_field('questionnaire_question', 'position', ['id' => $qid]));
+ }
+
+ /**
+ * soft_delete() stamps the deleted field with a current timestamp.
+ */
+ public function test_soft_delete(): void {
+ global $DB;
+ $this->resetAfterTest();
+ $sid = 8010;
+ $qid = $this->make_question($sid, 1);
+
+ $before = time();
+ $this->assertTrue(question_record::soft_delete($qid));
+ $deleted = (int) $DB->get_field('questionnaire_question', 'deleted', ['id' => $qid]);
+
+ $this->assertGreaterThanOrEqual($before, $deleted);
+ }
+
+ /**
+ * get_active_after_position() returns active questions with greater positions, ordered ascending.
+ */
+ public function test_get_active_after_position(): void {
+ $this->resetAfterTest();
+ $sid = 8011;
+ $p1 = $this->make_question($sid, 1);
+ $p2 = $this->make_question($sid, 2);
+ $p3 = $this->make_question($sid, 3);
+ $this->make_question($sid, 4, 1, time()); // Soft-deleted — excluded.
+
+ $records = array_values(question_record::get_active_after_position($sid, 1));
+ $this->assertCount(2, $records);
+ $this->assertSame($p2, $records[0]->get('id'));
+ $this->assertSame($p3, $records[1]->get('id'));
+
+ $this->assertCount(0, question_record::get_active_after_position($sid, 10));
+ }
+
+ /**
+ * create_pagebreak() inserts a pagebreak row at the given position.
+ */
+ public function test_create_pagebreak(): void {
+ global $DB;
+ $this->resetAfterTest();
+ $sid = 8012;
+ $rec = question_record::create_pagebreak($sid, 5);
+
+ $this->assertGreaterThan(0, $rec->get('id'));
+ $row = $DB->get_record('questionnaire_question', ['id' => $rec->get('id')]);
+ $this->assertEquals($sid, $row->surveyid);
+ $this->assertEquals(question_type::QUESPAGEBREAK, $row->typeid);
+ $this->assertEquals(5, $row->position);
+ $this->assertEquals('break', $row->content);
+ }
+
+ /**
+ * delete_for_survey() removes only the given survey's question rows.
+ */
+ public function test_delete_for_survey_scoped(): void {
+ global $DB;
+ $this->resetAfterTest();
+ $sid = 8013;
+ $other = 8014;
+ $this->make_question($sid, 1);
+ $this->make_question($sid, 2);
+ $keep = $this->make_question($other, 1);
+
+ $this->assertTrue(question_record::delete_for_survey($sid));
+
+ $this->assertEquals(0, $DB->count_records('questionnaire_question', ['surveyid' => $sid]));
+ $this->assertTrue($DB->record_exists('questionnaire_question', ['id' => $keep]));
+ }
+
+ /**
+ * delete_soft_deleted() permanently removes a soft-deleted row, but only if it's soft-deleted.
+ */
+ public function test_delete_soft_deleted(): void {
+ global $DB;
+ $this->resetAfterTest();
+ $sid = 8015;
+ $active = $this->make_question($sid, 1);
+ $deleted = $this->make_question($sid, 2, 1, time());
+
+ // Active rows are not removed.
+ $this->assertTrue(question_record::delete_soft_deleted($active, $sid));
+ $this->assertTrue($DB->record_exists('questionnaire_question', ['id' => $active]));
+
+ // Wrong survey: not removed.
+ $this->assertTrue(question_record::delete_soft_deleted($deleted, 99999));
+ $this->assertTrue($DB->record_exists('questionnaire_question', ['id' => $deleted]));
+
+ // Correctly scoped soft-deleted row is removed.
+ $this->assertTrue(question_record::delete_soft_deleted($deleted, $sid));
+ $this->assertFalse($DB->record_exists('questionnaire_question', ['id' => $deleted]));
+ }
+
+ /**
+ * delete_soft_deleted_pagebreaks_for_survey() removes only soft-deleted pagebreak rows.
+ */
+ public function test_delete_soft_deleted_pagebreaks_for_survey(): void {
+ global $DB;
+ $this->resetAfterTest();
+ $sid = 8016;
+ $other = 8017;
+ $activepb = $this->make_question($sid, 1, question_type::QUESPAGEBREAK);
+ $deletedpb = $this->make_question($sid, 2, question_type::QUESPAGEBREAK, time());
+ $deletedquestion = $this->make_question($sid, 3, 1, time()); // Soft-deleted but not pagebreak.
+ $otherdeleted = $this->make_question($other, 1, question_type::QUESPAGEBREAK, time());
+
+ $this->assertTrue(question_record::delete_soft_deleted_pagebreaks_for_survey($sid));
+
+ $this->assertTrue($DB->record_exists('questionnaire_question', ['id' => $activepb]));
+ $this->assertFalse($DB->record_exists('questionnaire_question', ['id' => $deletedpb]));
+ $this->assertTrue($DB->record_exists('questionnaire_question', ['id' => $deletedquestion]));
+ $this->assertTrue($DB->record_exists('questionnaire_question', ['id' => $otherdeleted]));
+ }
+}
diff --git a/tests/local/db/question_type_record_test.php b/tests/local/db/question_type_record_test.php
new file mode 100644
index 000000000..8db3d2336
--- /dev/null
+++ b/tests/local/db/question_type_record_test.php
@@ -0,0 +1,77 @@
+.
+
+/**
+ * Unit tests for mod_questionnaire\local\db\question_type_record.
+ *
+ * @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\local\db;
+
+use mod_questionnaire\local\question_type;
+
+/**
+ * Unit tests for mod_questionnaire\local\db\question_type_record.
+ *
+ * The questionnaire_question_type table is populated at install time,
+ * so the test relies on the pre-seeded entries rather than inserting new ones.
+ *
+ * @group mod_questionnaire
+ * @covers \mod_questionnaire\local\db\question_type_record
+ */
+final class question_type_record_test extends \advanced_testcase {
+ /**
+ * get_by_typeid() returns the installed record for a known typeid.
+ */
+ public function test_get_by_typeid_returns_known_type(): void {
+ $this->resetAfterTest();
+ $record = question_type_record::get_by_typeid(question_type::QUESTEXT);
+
+ $this->assertNotFalse($record);
+ $this->assertSame(question_type::QUESTEXT, (int) $record->get('typeid'));
+ }
+
+ /**
+ * get_by_typeid() returns false for an unknown typeid.
+ */
+ public function test_get_by_typeid_returns_false_for_unknown(): void {
+ $this->resetAfterTest();
+ $this->assertFalse(question_type_record::get_by_typeid(9999));
+ }
+
+ /**
+ * from_typeid() returns the installed record for a known typeid.
+ */
+ public function test_from_typeid_returns_known_type(): void {
+ $this->resetAfterTest();
+ $record = question_type_record::from_typeid(question_type::QUESYESNO);
+
+ $this->assertNotNull($record);
+ $this->assertSame(question_type::QUESYESNO, (int) $record->get('typeid'));
+ }
+
+ /**
+ * from_typeid() returns null for an unknown typeid.
+ */
+ public function test_from_typeid_returns_null_for_unknown(): void {
+ $this->resetAfterTest();
+ $this->assertNull(question_type_record::from_typeid(9999));
+ }
+}
diff --git a/tests/local/db/questionnaire_record_test.php b/tests/local/db/questionnaire_record_test.php
new file mode 100644
index 000000000..9d16a3d97
--- /dev/null
+++ b/tests/local/db/questionnaire_record_test.php
@@ -0,0 +1,120 @@
+.
+
+/**
+ * Unit tests for mod_questionnaire\local\db\questionnaire_record.
+ *
+ * @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\local\db;
+
+/**
+ * Unit tests for mod_questionnaire\local\db\questionnaire_record.
+ *
+ * @group mod_questionnaire
+ * @covers \mod_questionnaire\local\db\questionnaire_record
+ */
+final class questionnaire_record_test extends \advanced_testcase {
+ /**
+ * Insert a questionnaire row via $DB and return its id.
+ *
+ * @param int $courseid
+ * @param int $sid
+ * @param string $name
+ * @return int
+ */
+ private function make_questionnaire(int $courseid, int $sid, string $name = 'Q'): int {
+ global $DB;
+ return $DB->insert_record('questionnaire', (object)[
+ 'course' => $courseid,
+ 'name' => $name,
+ 'intro' => '',
+ 'introformat' => FORMAT_HTML,
+ 'qtype' => 0,
+ 'respondenttype' => 'fullname',
+ 'respeligible' => 'all',
+ 'respview' => 0,
+ 'sid' => $sid,
+ 'timemodified' => time(),
+ ]);
+ }
+
+ /**
+ * get_for_survey() returns the first questionnaire pointing at the survey, or null.
+ */
+ public function test_get_for_survey(): void {
+ $this->resetAfterTest();
+ $sid = 7501;
+ $this->assertNull(questionnaire_record::get_for_survey($sid));
+
+ $qid = $this->make_questionnaire(1, $sid);
+ $found = questionnaire_record::get_for_survey($sid);
+ $this->assertNotNull($found);
+ $this->assertSame($qid, $found->get('id'));
+ }
+
+ /**
+ * get_for_survey_in_course() finds the questionnaire whose course owns the survey.
+ */
+ public function test_get_for_survey_in_course(): void {
+ $this->resetAfterTest();
+ $sid = 7502;
+ $course1 = 11;
+ $course2 = 22;
+
+ $q1 = $this->make_questionnaire($course1, $sid, 'Original');
+ $q2 = $this->make_questionnaire($course2, $sid, 'Copy');
+
+ $found = questionnaire_record::get_for_survey_in_course($sid, $course1);
+ $this->assertNotNull($found);
+ $this->assertSame($q1, $found->get('id'));
+
+ $found2 = questionnaire_record::get_for_survey_in_course($sid, $course2);
+ $this->assertSame($q2, $found2->get('id'));
+
+ $this->assertNull(questionnaire_record::get_for_survey_in_course($sid, 999));
+ }
+
+ /**
+ * create_from_formdata() creates a new questionnaire row from a stdClass.
+ */
+ public function test_create_from_formdata(): void {
+ global $DB;
+ $this->resetAfterTest();
+
+ $formdata = (object)[
+ 'course' => 5,
+ 'name' => 'Form created',
+ 'intro' => '',
+ 'introformat' => FORMAT_HTML,
+ 'qtype' => 0,
+ 'respondenttype' => 'fullname',
+ 'respeligible' => 'all',
+ 'respview' => 0,
+ 'sid' => 123,
+ 'timemodified' => time(),
+ ];
+ $record = questionnaire_record::create_from_formdata($formdata);
+
+ $this->assertGreaterThan(0, $record->get('id'));
+ $this->assertTrue($DB->record_exists('questionnaire', ['id' => $record->get('id')]));
+ $this->assertSame('Form created', $record->get('name'));
+ }
+}
diff --git a/tests/local/db/response_bool_record_test.php b/tests/local/db/response_bool_record_test.php
new file mode 100644
index 000000000..a59fd8b25
--- /dev/null
+++ b/tests/local/db/response_bool_record_test.php
@@ -0,0 +1,55 @@
+.
+
+/**
+ * Unit tests for mod_questionnaire\local\db\response_bool_record.
+ *
+ * @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\local\db;
+
+/**
+ * Unit tests for mod_questionnaire\local\db\response_bool_record.
+ *
+ * @group mod_questionnaire
+ * @covers \mod_questionnaire\local\db\response_bool_record
+ */
+final class response_bool_record_test extends \advanced_testcase {
+ /**
+ * get_for_response() returns every yes/no answer for the response.
+ */
+ public function test_get_for_response(): void {
+ global $DB;
+ $this->resetAfterTest();
+ $rid = 11001;
+ $DB->insert_record('questionnaire_response_bool', (object)[
+ 'responseid' => $rid, 'questionid' => 1, 'choiceid' => 'y',
+ ]);
+ $DB->insert_record('questionnaire_response_bool', (object)[
+ 'responseid' => $rid, 'questionid' => 2, 'choiceid' => 'n',
+ ]);
+ $DB->insert_record('questionnaire_response_bool', (object)[
+ 'responseid' => 11002, 'questionid' => 1, 'choiceid' => 'y',
+ ]);
+
+ $this->assertCount(2, response_bool_record::get_for_response($rid));
+ $this->assertCount(0, response_bool_record::get_for_response(99999));
+ }
+}
diff --git a/tests/local/db/response_date_record_test.php b/tests/local/db/response_date_record_test.php
new file mode 100644
index 000000000..553c80842
--- /dev/null
+++ b/tests/local/db/response_date_record_test.php
@@ -0,0 +1,55 @@
+.
+
+/**
+ * Unit tests for mod_questionnaire\local\db\response_date_record.
+ *
+ * @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\local\db;
+
+/**
+ * Unit tests for mod_questionnaire\local\db\response_date_record.
+ *
+ * @group mod_questionnaire
+ * @covers \mod_questionnaire\local\db\response_date_record
+ */
+final class response_date_record_test extends \advanced_testcase {
+ /**
+ * get_for_response() returns every date answer for the response.
+ */
+ public function test_get_for_response(): void {
+ global $DB;
+ $this->resetAfterTest();
+ $rid = 12001;
+ $DB->insert_record('questionnaire_response_date', (object)[
+ 'responseid' => $rid, 'questionid' => 1, 'response' => '2026-01-01',
+ ]);
+ $DB->insert_record('questionnaire_response_date', (object)[
+ 'responseid' => $rid, 'questionid' => 2, 'response' => '2026-02-02',
+ ]);
+ $DB->insert_record('questionnaire_response_date', (object)[
+ 'responseid' => 12002, 'questionid' => 1, 'response' => '2026-03-03',
+ ]);
+
+ $this->assertCount(2, response_date_record::get_for_response($rid));
+ $this->assertCount(0, response_date_record::get_for_response(99999));
+ }
+}
diff --git a/tests/local/db/response_file_record_test.php b/tests/local/db/response_file_record_test.php
new file mode 100644
index 000000000..5919b5603
--- /dev/null
+++ b/tests/local/db/response_file_record_test.php
@@ -0,0 +1,55 @@
+.
+
+/**
+ * Unit tests for mod_questionnaire\local\db\response_file_record.
+ *
+ * @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\local\db;
+
+/**
+ * Unit tests for mod_questionnaire\local\db\response_file_record.
+ *
+ * @group mod_questionnaire
+ * @covers \mod_questionnaire\local\db\response_file_record
+ */
+final class response_file_record_test extends \advanced_testcase {
+ /**
+ * get_for_response() returns every file answer for the response.
+ */
+ public function test_get_for_response(): void {
+ global $DB;
+ $this->resetAfterTest();
+ $rid = 13001;
+ $DB->insert_record('questionnaire_response_file', (object)[
+ 'responseid' => $rid, 'questionid' => 1, 'fileid' => 100,
+ ]);
+ $DB->insert_record('questionnaire_response_file', (object)[
+ 'responseid' => $rid, 'questionid' => 1, 'fileid' => 101,
+ ]);
+ $DB->insert_record('questionnaire_response_file', (object)[
+ 'responseid' => 13002, 'questionid' => 1, 'fileid' => 102,
+ ]);
+
+ $this->assertCount(2, response_file_record::get_for_response($rid));
+ $this->assertCount(0, response_file_record::get_for_response(99999));
+ }
+}
diff --git a/tests/local/db/response_multiple_record_test.php b/tests/local/db/response_multiple_record_test.php
new file mode 100644
index 000000000..85fecfc20
--- /dev/null
+++ b/tests/local/db/response_multiple_record_test.php
@@ -0,0 +1,55 @@
+.
+
+/**
+ * Unit tests for mod_questionnaire\local\db\response_multiple_record.
+ *
+ * @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\local\db;
+
+/**
+ * Unit tests for mod_questionnaire\local\db\response_multiple_record.
+ *
+ * @group mod_questionnaire
+ * @covers \mod_questionnaire\local\db\response_multiple_record
+ */
+final class response_multiple_record_test extends \advanced_testcase {
+ /**
+ * get_for_response() returns every multiple-choice answer for the response.
+ */
+ public function test_get_for_response(): void {
+ global $DB;
+ $this->resetAfterTest();
+ $rid = 14001;
+ $DB->insert_record('questionnaire_resp_multiple', (object)[
+ 'responseid' => $rid, 'questionid' => 1, 'choiceid' => 11,
+ ]);
+ $DB->insert_record('questionnaire_resp_multiple', (object)[
+ 'responseid' => $rid, 'questionid' => 1, 'choiceid' => 12,
+ ]);
+ $DB->insert_record('questionnaire_resp_multiple', (object)[
+ 'responseid' => 14002, 'questionid' => 1, 'choiceid' => 11,
+ ]);
+
+ $this->assertCount(2, response_multiple_record::get_for_response($rid));
+ $this->assertCount(0, response_multiple_record::get_for_response(99999));
+ }
+}
diff --git a/tests/local/db/response_other_record_test.php b/tests/local/db/response_other_record_test.php
new file mode 100644
index 000000000..dfcded188
--- /dev/null
+++ b/tests/local/db/response_other_record_test.php
@@ -0,0 +1,55 @@
+.
+
+/**
+ * Unit tests for mod_questionnaire\local\db\response_other_record.
+ *
+ * @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\local\db;
+
+/**
+ * Unit tests for mod_questionnaire\local\db\response_other_record.
+ *
+ * @group mod_questionnaire
+ * @covers \mod_questionnaire\local\db\response_other_record
+ */
+final class response_other_record_test extends \advanced_testcase {
+ /**
+ * get_for_response() returns every "other" answer for the response.
+ */
+ public function test_get_for_response(): void {
+ global $DB;
+ $this->resetAfterTest();
+ $rid = 15001;
+ $DB->insert_record('questionnaire_response_other', (object)[
+ 'responseid' => $rid, 'questionid' => 1, 'choiceid' => 5, 'response' => 'free text 1',
+ ]);
+ $DB->insert_record('questionnaire_response_other', (object)[
+ 'responseid' => $rid, 'questionid' => 2, 'choiceid' => 5, 'response' => 'free text 2',
+ ]);
+ $DB->insert_record('questionnaire_response_other', (object)[
+ 'responseid' => 15002, 'questionid' => 1, 'choiceid' => 5, 'response' => 'other',
+ ]);
+
+ $this->assertCount(2, response_other_record::get_for_response($rid));
+ $this->assertCount(0, response_other_record::get_for_response(99999));
+ }
+}
diff --git a/tests/local/db/response_rank_record_test.php b/tests/local/db/response_rank_record_test.php
new file mode 100644
index 000000000..769e2cf67
--- /dev/null
+++ b/tests/local/db/response_rank_record_test.php
@@ -0,0 +1,129 @@
+.
+
+/**
+ * Unit tests for mod_questionnaire\local\db\response_rank_record.
+ *
+ * @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\local\db;
+
+/**
+ * Unit tests for mod_questionnaire\local\db\response_rank_record.
+ *
+ * @group mod_questionnaire
+ * @covers \mod_questionnaire\local\db\response_rank_record
+ */
+final class response_rank_record_test extends \advanced_testcase {
+ /**
+ * Insert a rank row via $DB and return its id.
+ *
+ * @param int $responseid
+ * @param int $questionid
+ * @param int $choiceid
+ * @param int $rankvalue
+ * @return int
+ */
+ private function make_rank(int $responseid, int $questionid, int $choiceid, int $rankvalue): int {
+ global $DB;
+ return $DB->insert_record('questionnaire_response_rank', (object)[
+ 'responseid' => $responseid,
+ 'questionid' => $questionid,
+ 'choiceid' => $choiceid,
+ 'rankvalue' => $rankvalue,
+ ]);
+ }
+
+ /**
+ * get_for_response() returns every rank row for the response.
+ */
+ public function test_get_for_response(): void {
+ $this->resetAfterTest();
+ $rid = 501;
+ $this->make_rank($rid, 1, 10, 2);
+ $this->make_rank($rid, 1, 11, 3);
+ $this->make_rank(502, 1, 10, 1); // Different response.
+
+ $this->assertCount(2, response_rank_record::get_for_response($rid));
+ }
+
+ /**
+ * get_for_question() returns every rank row for the question.
+ */
+ public function test_get_for_question(): void {
+ $this->resetAfterTest();
+ $qid = 600;
+ $this->make_rank(1, $qid, 1, 0);
+ $this->make_rank(2, $qid, 1, 1);
+ $this->make_rank(1, 601, 1, 0); // Different question.
+
+ $this->assertCount(2, response_rank_record::get_for_question($qid));
+ $this->assertCount(0, response_rank_record::get_for_question(99999));
+ }
+
+ /**
+ * increment_rankvalues() bumps every non-negative rankvalue by one across all rows.
+ */
+ public function test_increment_rankvalues_all_rows(): void {
+ global $DB;
+ $this->resetAfterTest();
+
+ $a = $this->make_rank(1, 1, 1, 0);
+ $b = $this->make_rank(1, 1, 2, 3);
+ $c = $this->make_rank(1, 1, 3, -1); // Sentinel; should not increment.
+
+ $this->assertTrue(response_rank_record::increment_rankvalues());
+
+ $this->assertSame(1, (int) $DB->get_field('questionnaire_response_rank', 'rankvalue', ['id' => $a]));
+ $this->assertSame(4, (int) $DB->get_field('questionnaire_response_rank', 'rankvalue', ['id' => $b]));
+ $this->assertSame(-1, (int) $DB->get_field('questionnaire_response_rank', 'rankvalue', ['id' => $c]));
+ }
+
+ /**
+ * increment_rankvalues() with a question-id filter only touches matching rows.
+ */
+ public function test_increment_rankvalues_filtered_by_question(): void {
+ global $DB;
+ $this->resetAfterTest();
+
+ $target = 700;
+ $other = 701;
+ $a = $this->make_rank(1, $target, 1, 2);
+ $b = $this->make_rank(2, $other, 1, 2);
+
+ $this->assertTrue(response_rank_record::increment_rankvalues([$target]));
+
+ $this->assertSame(3, (int) $DB->get_field('questionnaire_response_rank', 'rankvalue', ['id' => $a]));
+ $this->assertSame(2, (int) $DB->get_field('questionnaire_response_rank', 'rankvalue', ['id' => $b]));
+ }
+
+ /**
+ * increment_rankvalues() with an empty filter is a no-op and returns true.
+ */
+ public function test_increment_rankvalues_empty_filter_noop(): void {
+ global $DB;
+ $this->resetAfterTest();
+ $a = $this->make_rank(1, 1, 1, 5);
+
+ $this->assertTrue(response_rank_record::increment_rankvalues([]));
+
+ $this->assertSame(5, (int) $DB->get_field('questionnaire_response_rank', 'rankvalue', ['id' => $a]));
+ }
+}
diff --git a/tests/local/db/response_record_test.php b/tests/local/db/response_record_test.php
new file mode 100644
index 000000000..45fce3a82
--- /dev/null
+++ b/tests/local/db/response_record_test.php
@@ -0,0 +1,313 @@
+.
+
+/**
+ * Unit tests for mod_questionnaire\local\db\response_record.
+ *
+ * @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\local\db;
+
+/**
+ * Unit tests for mod_questionnaire\local\db\response_record.
+ *
+ * @group mod_questionnaire
+ * @covers \mod_questionnaire\local\db\response_record
+ */
+final class response_record_test extends \advanced_testcase {
+ /**
+ * Insert a questionnaire_response row via $DB and return its id.
+ *
+ * @param int $questionnaireid
+ * @param int $userid
+ * @param string $complete 'y' or 'n'.
+ * @param int|null $submitted
+ * @return int
+ */
+ private function make_response(int $questionnaireid, int $userid, string $complete = 'y', ?int $submitted = null): int {
+ global $DB;
+ return $DB->insert_record('questionnaire_response', (object)[
+ 'questionnaireid' => $questionnaireid,
+ 'userid' => $userid,
+ 'submitted' => $submitted ?? time(),
+ 'complete' => $complete,
+ 'grade' => 0,
+ ]);
+ }
+
+ /**
+ * Insert a questionnaire row pointing at a specific survey.
+ *
+ * @param int $courseid
+ * @param int $sid
+ * @return int
+ */
+ private function make_questionnaire(int $courseid, int $sid): int {
+ global $DB;
+ return $DB->insert_record('questionnaire', (object)[
+ 'course' => $courseid,
+ 'name' => 'Q',
+ 'intro' => '',
+ 'introformat' => FORMAT_HTML,
+ 'qtype' => 0,
+ 'respondenttype' => 'fullname',
+ 'respeligible' => 'all',
+ 'respview' => 0,
+ 'sid' => $sid,
+ 'timemodified' => time(),
+ ]);
+ }
+
+ /**
+ * Insert a survey row.
+ *
+ * @param int $courseid
+ * @param string $realm
+ * @param string $name
+ * @return int
+ */
+ private function make_survey(int $courseid, string $realm = 'private', string $name = 'S'): int {
+ global $DB;
+ return $DB->insert_record('questionnaire_survey', (object)[
+ 'name' => $name,
+ 'courseid' => $courseid,
+ 'realm' => $realm,
+ 'status' => 0,
+ 'title' => $name,
+ ]);
+ }
+
+ /**
+ * get_or_null() returns the record when it exists and null when it doesn't.
+ */
+ public function test_get_or_null(): void {
+ $this->resetAfterTest();
+ $rid = $this->make_response(8000, 1, 'y');
+
+ $record = response_record::get_or_null($rid);
+ $this->assertInstanceOf(response_record::class, $record);
+ $this->assertEquals($rid, $record->get('id'));
+ $this->assertEquals(8000, $record->get('questionnaireid'));
+
+ $this->assertNull(response_record::get_or_null(999999));
+ $this->assertNull(response_record::get_or_null(0));
+ }
+
+ /**
+ * get_course_fullname() returns the owning course's fullname for a complete response.
+ */
+ public function test_get_course_fullname(): void {
+ $this->resetAfterTest();
+ $course = $this->getDataGenerator()->create_course(['fullname' => 'Shared course']);
+ $sid = $this->make_survey((int)$course->id, 'public');
+ $qid = $this->make_questionnaire((int)$course->id, $sid);
+ $completerid = $this->make_response($qid, 1, 'y');
+ $incompleterid = $this->make_response($qid, 1, 'n');
+
+ $this->assertSame('Shared course', response_record::get_course_fullname($completerid));
+ $this->assertNull(response_record::get_course_fullname($incompleterid));
+ $this->assertNull(response_record::get_course_fullname(999999));
+ }
+
+ /**
+ * get_for_questionnaire() returns every response for the questionnaire.
+ */
+ public function test_get_for_questionnaire(): void {
+ $this->resetAfterTest();
+ $qid = 9001;
+ $this->make_response($qid, 1, 'y');
+ $this->make_response($qid, 2, 'n');
+ $this->make_response(9002, 1, 'y');
+
+ $this->assertCount(2, response_record::get_for_questionnaire($qid));
+ $this->assertCount(0, response_record::get_for_questionnaire(99999));
+ }
+
+ /**
+ * get_complete_for_user() returns only the user's complete responses.
+ */
+ public function test_get_complete_for_user(): void {
+ $this->resetAfterTest();
+ $qid = 9003;
+ $userid = 42;
+ $this->make_response($qid, $userid, 'y');
+ $this->make_response($qid, $userid, 'y');
+ $this->make_response($qid, $userid, 'n'); // Incomplete — excluded.
+ $this->make_response($qid, 43, 'y'); // Other user — excluded.
+
+ $this->assertCount(2, response_record::get_complete_for_user($qid, $userid));
+ }
+
+ /**
+ * user_has_complete_response() returns true only if a complete response exists.
+ */
+ public function test_user_has_complete_response(): void {
+ $this->resetAfterTest();
+ $qid = 9004;
+ $userid = 50;
+
+ $this->assertFalse(response_record::user_has_complete_response($qid, $userid));
+
+ $this->make_response($qid, $userid, 'n');
+ $this->assertFalse(response_record::user_has_complete_response($qid, $userid));
+
+ $this->make_response($qid, $userid, 'y');
+ $this->assertTrue(response_record::user_has_complete_response($qid, $userid));
+ }
+
+ /**
+ * user_has_saved_response() returns true only if an incomplete response exists.
+ */
+ public function test_user_has_saved_response(): void {
+ $this->resetAfterTest();
+ $qid = 9005;
+ $userid = 51;
+
+ $this->assertFalse(response_record::user_has_saved_response($qid, $userid));
+
+ $this->make_response($qid, $userid, 'y');
+ $this->assertFalse(response_record::user_has_saved_response($qid, $userid));
+
+ $this->make_response($qid, $userid, 'n');
+ $this->assertTrue(response_record::user_has_saved_response($qid, $userid));
+ }
+
+ /**
+ * get_latest_incomplete() returns the most recent incomplete response, or null.
+ */
+ public function test_get_latest_incomplete(): void {
+ $this->resetAfterTest();
+ $qid = 9006;
+ $userid = 52;
+
+ $this->assertNull(response_record::get_latest_incomplete($qid, $userid));
+
+ $older = $this->make_response($qid, $userid, 'n', time() - 3600);
+ $newer = $this->make_response($qid, $userid, 'n', time());
+ $this->make_response($qid, $userid, 'y'); // Complete — excluded.
+
+ $latest = response_record::get_latest_incomplete($qid, $userid);
+ $this->assertNotNull($latest);
+ $this->assertSame($newer, $latest->get('id'));
+ }
+
+ /**
+ * get_latest_complete_for_user() returns the most recent complete response, or null.
+ */
+ public function test_get_latest_complete_for_user(): void {
+ $this->resetAfterTest();
+ $qid = 9050;
+ $userid = 73;
+
+ $this->assertNull(response_record::get_latest_complete_for_user($qid, $userid));
+
+ $older = $this->make_response($qid, $userid, 'y', time() - 3600);
+ $newer = $this->make_response($qid, $userid, 'y', time());
+ $this->make_response($qid, $userid, 'n'); // Incomplete — excluded.
+ $this->make_response($qid, 74, 'y'); // Other user — excluded.
+
+ $latest = response_record::get_latest_complete_for_user($qid, $userid);
+ $this->assertNotNull($latest);
+ $this->assertSame($newer, $latest->get('id'));
+ }
+
+ /**
+ * count_complete_for_questionnaire() counts only complete responses scoped to the instance.
+ */
+ public function test_count_complete_for_questionnaire(): void {
+ $this->resetAfterTest();
+ $qid = 9007;
+ $this->make_response($qid, 1, 'y');
+ $this->make_response($qid, 2, 'y');
+ $this->make_response($qid, 3, 'n'); // Incomplete — excluded.
+ $this->make_response(9008, 1, 'y'); // Different questionnaire.
+
+ $this->assertSame(2, response_record::count_complete_for_questionnaire($qid));
+ }
+
+ /**
+ * count_complete_for_questionnaire() can restrict by user id.
+ */
+ public function test_count_complete_for_questionnaire_user_filter(): void {
+ $this->resetAfterTest();
+ $qid = 9009;
+ $this->make_response($qid, 1, 'y');
+ $this->make_response($qid, 1, 'y');
+ $this->make_response($qid, 2, 'y');
+
+ $this->assertSame(2, response_record::count_complete_for_questionnaire($qid, 1));
+ $this->assertSame(1, response_record::count_complete_for_questionnaire($qid, 2));
+ $this->assertSame(0, response_record::count_complete_for_questionnaire($qid, 99));
+ }
+
+ /**
+ * count_complete_for_questionnaire() can restrict by group membership.
+ */
+ public function test_count_complete_for_questionnaire_group_filter(): void {
+ global $DB;
+ $this->resetAfterTest();
+
+ $qid = 9010;
+ $this->make_response($qid, 1, 'y');
+ $this->make_response($qid, 2, 'y');
+ $this->make_response($qid, 3, 'y');
+
+ // Put users 1 and 2 in group 100 (raw insert — no need for a real course/group fixture).
+ $gid = 100;
+ $DB->insert_record('groups_members', (object)['groupid' => $gid, 'userid' => 1]);
+ $DB->insert_record('groups_members', (object)['groupid' => $gid, 'userid' => 2]);
+
+ $this->assertSame(2, response_record::count_complete_for_questionnaire($qid, false, $gid));
+ $this->assertSame(0, response_record::count_complete_for_questionnaire($qid, false, 999));
+ }
+
+ /**
+ * count_complete_for_public_survey() counts complete responses across every
+ * questionnaire instance pointing at the shared survey.
+ */
+ public function test_count_complete_for_public_survey(): void {
+ $this->resetAfterTest();
+ $sid = $this->make_survey(1, 'public');
+ $q1 = $this->make_questionnaire(11, $sid);
+ $q2 = $this->make_questionnaire(22, $sid);
+ $this->make_response($q1, 1, 'y');
+ $this->make_response($q1, 2, 'y');
+ $this->make_response($q2, 3, 'y');
+ $this->make_response($q1, 4, 'n'); // Incomplete — excluded.
+
+ $this->assertSame(3, response_record::count_complete_for_public_survey($sid));
+ }
+
+ /**
+ * count_complete_for_public_survey() can restrict by user id.
+ */
+ public function test_count_complete_for_public_survey_user_filter(): void {
+ $this->resetAfterTest();
+ $sid = $this->make_survey(1, 'public');
+ $q1 = $this->make_questionnaire(11, $sid);
+ $q2 = $this->make_questionnaire(22, $sid);
+ $this->make_response($q1, 1, 'y');
+ $this->make_response($q2, 1, 'y');
+ $this->make_response($q1, 2, 'y');
+
+ $this->assertSame(2, response_record::count_complete_for_public_survey($sid, 1));
+ $this->assertSame(1, response_record::count_complete_for_public_survey($sid, 2));
+ }
+}
diff --git a/tests/local/db/response_single_record_test.php b/tests/local/db/response_single_record_test.php
new file mode 100644
index 000000000..144275375
--- /dev/null
+++ b/tests/local/db/response_single_record_test.php
@@ -0,0 +1,55 @@
+.
+
+/**
+ * Unit tests for mod_questionnaire\local\db\response_single_record.
+ *
+ * @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\local\db;
+
+/**
+ * Unit tests for mod_questionnaire\local\db\response_single_record.
+ *
+ * @group mod_questionnaire
+ * @covers \mod_questionnaire\local\db\response_single_record
+ */
+final class response_single_record_test extends \advanced_testcase {
+ /**
+ * get_for_response() returns every single-choice answer for the response.
+ */
+ public function test_get_for_response(): void {
+ global $DB;
+ $this->resetAfterTest();
+ $rid = 16001;
+ $DB->insert_record('questionnaire_resp_single', (object)[
+ 'responseid' => $rid, 'questionid' => 1, 'choiceid' => 11,
+ ]);
+ $DB->insert_record('questionnaire_resp_single', (object)[
+ 'responseid' => $rid, 'questionid' => 2, 'choiceid' => 22,
+ ]);
+ $DB->insert_record('questionnaire_resp_single', (object)[
+ 'responseid' => 16002, 'questionid' => 1, 'choiceid' => 11,
+ ]);
+
+ $this->assertCount(2, response_single_record::get_for_response($rid));
+ $this->assertCount(0, response_single_record::get_for_response(99999));
+ }
+}
diff --git a/tests/local/db/response_text_record_test.php b/tests/local/db/response_text_record_test.php
new file mode 100644
index 000000000..0199987b0
--- /dev/null
+++ b/tests/local/db/response_text_record_test.php
@@ -0,0 +1,55 @@
+.
+
+/**
+ * Unit tests for mod_questionnaire\local\db\response_text_record.
+ *
+ * @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\local\db;
+
+/**
+ * Unit tests for mod_questionnaire\local\db\response_text_record.
+ *
+ * @group mod_questionnaire
+ * @covers \mod_questionnaire\local\db\response_text_record
+ */
+final class response_text_record_test extends \advanced_testcase {
+ /**
+ * get_for_response() returns every text answer for the response.
+ */
+ public function test_get_for_response(): void {
+ global $DB;
+ $this->resetAfterTest();
+ $rid = 17001;
+ $DB->insert_record('questionnaire_response_text', (object)[
+ 'responseid' => $rid, 'questionid' => 1, 'response' => 'answer 1',
+ ]);
+ $DB->insert_record('questionnaire_response_text', (object)[
+ 'responseid' => $rid, 'questionid' => 2, 'response' => 'answer 2',
+ ]);
+ $DB->insert_record('questionnaire_response_text', (object)[
+ 'responseid' => 17002, 'questionid' => 1, 'response' => 'other',
+ ]);
+
+ $this->assertCount(2, response_text_record::get_for_response($rid));
+ $this->assertCount(0, response_text_record::get_for_response(99999));
+ }
+}
diff --git a/tests/local/db/survey_record_test.php b/tests/local/db/survey_record_test.php
new file mode 100644
index 000000000..d974a9e1f
--- /dev/null
+++ b/tests/local/db/survey_record_test.php
@@ -0,0 +1,154 @@
+.
+
+/**
+ * Unit tests for mod_questionnaire\local\db\survey_record.
+ *
+ * @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\local\db;
+
+/**
+ * Unit tests for mod_questionnaire\local\db\survey_record.
+ *
+ * @group mod_questionnaire
+ * @covers \mod_questionnaire\local\db\survey_record
+ */
+final class survey_record_test extends \advanced_testcase {
+ /**
+ * Insert a survey row via $DB and return its id.
+ *
+ * @param int $courseid
+ * @param string $name
+ * @param string $realm
+ * @return int
+ */
+ private function make_survey(int $courseid, string $name, string $realm = 'private'): int {
+ global $DB;
+ return $DB->insert_record('questionnaire_survey', (object)[
+ 'name' => $name,
+ 'courseid' => $courseid,
+ 'realm' => $realm,
+ 'status' => 0,
+ 'title' => $name,
+ ]);
+ }
+
+ /**
+ * Insert a questionnaire row that points to the given survey.
+ *
+ * @param int $courseid
+ * @param int $sid
+ * @return int
+ */
+ private function make_questionnaire(int $courseid, int $sid): int {
+ global $DB;
+ return $DB->insert_record('questionnaire', (object)[
+ 'course' => $courseid,
+ 'name' => 'Q' . $sid,
+ 'intro' => '',
+ 'introformat' => FORMAT_HTML,
+ 'qtype' => 0,
+ 'respondenttype' => 'fullname',
+ 'respeligible' => 'all',
+ 'respview' => 0,
+ 'sid' => $sid,
+ 'timemodified' => time(),
+ ]);
+ }
+
+ /**
+ * record_exists() reports presence of a survey row by id.
+ */
+ public function test_record_exists(): void {
+ $this->resetAfterTest();
+ $sid = $this->make_survey(1, 'Existing survey');
+
+ $this->assertTrue(survey_record::record_exists($sid));
+ $this->assertFalse(survey_record::record_exists(99999));
+ }
+
+ /**
+ * get_by_realm_in_course() returns surveys matching realm + course.
+ */
+ public function test_get_by_realm_in_course(): void {
+ $this->resetAfterTest();
+ $course = 11;
+ $this->make_survey($course, 'A', 'private');
+ $this->make_survey($course, 'B', 'private');
+ $this->make_survey($course, 'C', 'public');
+ $this->make_survey(99, 'D', 'private');
+
+ $this->assertCount(2, survey_record::get_by_realm_in_course('private', $course));
+ $this->assertCount(1, survey_record::get_by_realm_in_course('public', $course));
+ $this->assertCount(0, survey_record::get_by_realm_in_course('template', $course));
+ }
+
+ /**
+ * get_by_realm() returns surveys of the realm across all courses.
+ */
+ public function test_get_by_realm(): void {
+ $this->resetAfterTest();
+ $this->make_survey(1, 'A', 'public');
+ $this->make_survey(2, 'B', 'public');
+ $this->make_survey(3, 'C', 'template');
+
+ $this->assertCount(2, survey_record::get_by_realm('public'));
+ $this->assertCount(1, survey_record::get_by_realm('template'));
+ }
+
+ /**
+ * get_orphaned() returns surveys with no linked questionnaire row.
+ */
+ public function test_get_orphaned(): void {
+ $this->resetAfterTest();
+ $linked = $this->make_survey(1, 'linked');
+ $orphan1 = $this->make_survey(1, 'orphan1');
+ $orphan2 = $this->make_survey(1, 'orphan2');
+ $this->make_questionnaire(1, $linked);
+
+ $orphans = survey_record::get_orphaned();
+ $ids = array_map(fn($r) => $r->get('id'), $orphans);
+
+ $this->assertContains($orphan1, $ids);
+ $this->assertContains($orphan2, $ids);
+ $this->assertNotContains($linked, $ids);
+ }
+
+ /**
+ * create_from_sdata() inserts a row and returns the persistent.
+ */
+ public function test_create_from_sdata(): void {
+ global $DB;
+ $this->resetAfterTest();
+ $sdata = (object)[
+ 'name' => 'Created via sdata',
+ 'courseid' => 7,
+ 'realm' => 'private',
+ 'status' => 0,
+ 'title' => 'Created via sdata',
+ ];
+ $record = survey_record::create_from_sdata($sdata);
+
+ $this->assertGreaterThan(0, $record->get('id'));
+ $this->assertTrue($DB->record_exists('questionnaire_survey', ['id' => $record->get('id')]));
+ $this->assertSame('Created via sdata', $record->get('name'));
+ }
+}
diff --git a/tests/local/feedback/chart_renderer_test.php b/tests/local/feedback/chart_renderer_test.php
new file mode 100644
index 000000000..4de577a7d
--- /dev/null
+++ b/tests/local/feedback/chart_renderer_test.php
@@ -0,0 +1,116 @@
+.
+
+/**
+ * Unit tests for mod_questionnaire\local\feedback\chart_renderer.
+ *
+ * @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\local\feedback;
+
+/**
+ * Unit tests for mod_questionnaire\local\feedback\chart_renderer.
+ *
+ * Asserts the canvas HTML the renderer emits. The chart spec passed to the
+ * mod_questionnaire/chart AMD module is serialised into the page's AMD
+ * init bundle and not directly introspectable from PHP — behavioural
+ * coverage of the in-browser chart rendering lives in the Behat feature
+ * feedback_usergraph.feature.
+ *
+ * @group mod_questionnaire
+ * @covers \mod_questionnaire\local\feedback\chart_renderer
+ */
+final class chart_renderer_test extends \advanced_testcase {
+ /**
+ * Render a 'global' feedbacktype + 'bipolar' chart with allscore set and
+ * assert both canvas placeholders appear in the returned HTML.
+ */
+ public function test_render_global_bipolar_emits_both_canvases(): void {
+ global $PAGE;
+ $this->resetAfterTest();
+
+ $html = chart_renderer::render(
+ $PAGE,
+ 'global',
+ [],
+ 'Group A',
+ false,
+ 'bipolar',
+ [70, 30],
+ [55, 45],
+ 'Speed | Quality',
+ 'Your response'
+ );
+
+ $this->assertStringContainsString('assertStringContainsString('-primary', $html);
+ $this->assertStringContainsString('-secondary', $html);
+ $this->assertStringContainsString('[No canvas support]', $html);
+ }
+
+ /**
+ * Render a 'sections' feedbacktype + 'radar' chart with allresponses=true so only
+ * the secondary "group" canvas is emitted.
+ */
+ public function test_render_sections_radar_allresponses_emits_secondary_only(): void {
+ global $PAGE;
+ $this->resetAfterTest();
+
+ $html = chart_renderer::render(
+ $PAGE,
+ 'sections',
+ ['Section 1', 'Section 2'],
+ 'All participants',
+ true,
+ 'radar',
+ [40, 60],
+ [55, 45],
+ null,
+ 'This response'
+ );
+
+ $this->assertStringContainsString('assertStringContainsString('-secondary', $html);
+ $this->assertStringNotContainsString('-primary', $html);
+ }
+
+ /**
+ * An unrecognised charttype short-circuits and returns an empty string.
+ */
+ public function test_render_unknown_charttype_returns_empty_string(): void {
+ global $PAGE;
+ $this->resetAfterTest();
+
+ $html = chart_renderer::render(
+ $PAGE,
+ 'global',
+ [],
+ 'Group A',
+ false,
+ 'not-a-real-charttype',
+ [50, 50],
+ null,
+ 'X',
+ 'Your response'
+ );
+
+ $this->assertSame('', $html);
+ }
+}
diff --git a/tests/local/feedback/feedback_settings_controller_test.php b/tests/local/feedback/feedback_settings_controller_test.php
new file mode 100644
index 000000000..acdcd2167
--- /dev/null
+++ b/tests/local/feedback/feedback_settings_controller_test.php
@@ -0,0 +1,168 @@
+.
+
+/**
+ * Unit tests for mod_questionnaire\local\feedback\feedback_settings_controller.
+ *
+ * @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\local\feedback;
+
+use mod_questionnaire\questionnaire;
+
+/**
+ * Unit tests for mod_questionnaire\local\feedback\feedback_settings_controller.
+ *
+ * @group mod_questionnaire
+ * @covers \mod_questionnaire\local\feedback\feedback_settings_controller
+ */
+final class feedback_settings_controller_test extends \advanced_testcase {
+ /**
+ * Build a course + questionnaire fixture and return its domain object.
+ *
+ * @return questionnaire
+ */
+ private function setup_questionnaire(): questionnaire {
+ $this->resetAfterTest();
+ $this->setAdminUser();
+ $course = $this->getDataGenerator()->create_course();
+ return $this->getDataGenerator()
+ ->get_plugin_generator('mod_questionnaire')
+ ->create_instance(['course' => $course->id]);
+ }
+
+ /**
+ * Asserts save() writes feedbacksections / feedbackscores onto the survey row.
+ */
+ public function test_save_persists_feedbacksections_and_feedbackscores(): void {
+ global $DB;
+
+ $questionnaire = $this->setup_questionnaire();
+ $formdata = (object) [
+ 'feedbacksections' => 1,
+ 'feedbackscores' => 1,
+ 'feedbacknotes' => ['itemid' => 0, 'format' => FORMAT_HTML, 'text' => 'Note text'],
+ ];
+
+ $result = (new feedback_settings_controller($questionnaire))->save($formdata);
+
+ $this->assertSame($questionnaire->surveyid(), $result);
+ $row = $DB->get_record('questionnaire_survey', ['id' => $questionnaire->surveyid()]);
+ $this->assertEquals(1, $row->feedbacksections);
+ $this->assertEquals(1, $row->feedbackscores);
+ $this->assertStringContainsString('Note text', $row->feedbacknotes);
+ }
+
+ /**
+ * Asserts save() resets feedbacksections to 0 when the form submits a zero value.
+ */
+ public function test_save_resets_feedbacksections_to_zero(): void {
+ global $DB;
+
+ $questionnaire = $this->setup_questionnaire();
+ // Seed feedbacksections = 2 first.
+ $questionnaire->feedback()->update_settings(['feedbacksections' => 2]);
+
+ $formdata = (object) [
+ 'feedbacksections' => 0,
+ 'feedbackscores' => 0,
+ 'feedbacknotes' => ['itemid' => 0, 'format' => FORMAT_HTML, 'text' => ''],
+ ];
+ (new feedback_settings_controller($questionnaire))->save($formdata);
+
+ $this->assertEquals(0, $DB->get_field('questionnaire_survey', 'feedbacksections', ['id' => $questionnaire->surveyid()]));
+ }
+
+ /**
+ * Asserts save() picks chart_type_global when sections=1 and usergraph is on.
+ */
+ public function test_save_picks_chart_type_global_when_one_section_with_usergraph(): void {
+ global $DB;
+
+ $questionnaire = $this->setup_questionnaire();
+ set_config('usergraph', 1, 'questionnaire');
+
+ $formdata = (object) [
+ 'feedbacksections' => 1,
+ 'feedbackscores' => 1,
+ 'feedbacknotes' => ['itemid' => 0, 'format' => FORMAT_HTML, 'text' => ''],
+ 'chart_type_global' => 'bipolar',
+ 'chart_type_two_sections' => 'rose',
+ 'chart_type_sections' => 'hbar',
+ ];
+ (new feedback_settings_controller($questionnaire))->save($formdata);
+
+ $this->assertEquals('bipolar', $DB->get_field('questionnaire_survey', 'charttype', ['id' => $questionnaire->surveyid()]));
+ }
+
+ /**
+ * Asserts save() picks chart_type_sections when sections > 2 and usergraph is on.
+ */
+ public function test_save_picks_chart_type_sections_when_many_with_usergraph(): void {
+ global $DB;
+
+ $questionnaire = $this->setup_questionnaire();
+ set_config('usergraph', 1, 'questionnaire');
+
+ $formdata = (object) [
+ 'feedbacksections' => 4,
+ 'feedbackscores' => 1,
+ 'feedbacknotes' => ['itemid' => 0, 'format' => FORMAT_HTML, 'text' => ''],
+ 'chart_type_global' => 'bipolar',
+ 'chart_type_two_sections' => 'rose',
+ 'chart_type_sections' => 'radar',
+ ];
+ (new feedback_settings_controller($questionnaire))->save($formdata);
+
+ $this->assertEquals('radar', $DB->get_field('questionnaire_survey', 'charttype', ['id' => $questionnaire->surveyid()]));
+ }
+
+ /**
+ * Asserts ensure_first_section() creates a section when none exist and feedback is enabled.
+ */
+ public function test_ensure_first_section_creates_section_when_missing(): void {
+ global $DB;
+
+ $questionnaire = $this->setup_questionnaire();
+ $questionnaire->feedback()->update_settings(['feedbacksections' => 1]);
+
+ $surveyid = $questionnaire->surveyid();
+ $this->assertEquals(0, $DB->count_records('questionnaire_fb_sections', ['surveyid' => $surveyid]));
+
+ (new feedback_settings_controller($questionnaire))->ensure_first_section();
+
+ $this->assertEquals(1, $DB->count_records('questionnaire_fb_sections', ['surveyid' => $surveyid]));
+ }
+
+ /**
+ * Asserts ensure_first_section() does NOT create a section when feedbacksections == 0.
+ */
+ public function test_ensure_first_section_no_op_when_feedback_disabled(): void {
+ global $DB;
+
+ $questionnaire = $this->setup_questionnaire();
+ $surveyid = $questionnaire->surveyid();
+ $this->assertEquals(0, $DB->count_records('questionnaire_fb_sections', ['surveyid' => $surveyid]));
+
+ (new feedback_settings_controller($questionnaire))->ensure_first_section();
+
+ $this->assertEquals(0, $DB->count_records('questionnaire_fb_sections', ['surveyid' => $surveyid]));
+ }
+}
diff --git a/tests/local/feedback/feedback_test.php b/tests/local/feedback/feedback_test.php
new file mode 100644
index 000000000..29a8d0a6d
--- /dev/null
+++ b/tests/local/feedback/feedback_test.php
@@ -0,0 +1,214 @@
+.
+
+/**
+ * Unit tests for mod_questionnaire\local\feedback\feedback.
+ *
+ * @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\local\feedback;
+
+use mod_questionnaire\questionnaire;
+
+/**
+ * Unit tests for mod_questionnaire\local\feedback\feedback.
+ *
+ * @group mod_questionnaire
+ * @covers \mod_questionnaire\local\feedback\feedback
+ */
+final class feedback_test extends \advanced_testcase {
+ /**
+ * Build a course + questionnaire fixture and return its domain object.
+ *
+ * @return questionnaire
+ */
+ private function setup_questionnaire(): questionnaire {
+ $this->resetAfterTest();
+ $this->setAdminUser();
+ $course = $this->getDataGenerator()->create_course();
+ return $this->getDataGenerator()
+ ->get_plugin_generator('mod_questionnaire')
+ ->create_instance(['course' => $course->id]);
+ }
+
+ /**
+ * mode() / enabled() reflect the feedbacksections column.
+ */
+ public function test_mode_and_enabled_reflect_feedbacksections(): void {
+ $questionnaire = $this->setup_questionnaire();
+
+ $this->assertSame(0, $questionnaire->feedback()->mode());
+ $this->assertFalse($questionnaire->feedback()->enabled());
+
+ $questionnaire->feedback()->update_settings(['feedbacksections' => 1]);
+ $questionnaire = questionnaire::from_instanceid($questionnaire->id());
+ $this->assertSame(1, $questionnaire->feedback()->mode());
+ $this->assertTrue($questionnaire->feedback()->enabled());
+
+ $questionnaire->feedback()->update_settings(['feedbacksections' => 5]);
+ $questionnaire = questionnaire::from_instanceid($questionnaire->id());
+ $this->assertSame(5, $questionnaire->feedback()->mode());
+ $this->assertTrue($questionnaire->feedback()->enabled());
+ }
+
+ /**
+ * show_scores() / notes() / chart_type() pass through the matching survey fields.
+ */
+ public function test_field_accessors_pass_through(): void {
+ $questionnaire = $this->setup_questionnaire();
+ $questionnaire->feedback()->update_settings([
+ 'feedbackscores' => 1,
+ 'feedbacknotes' => 'Hello
',
+ 'charttype' => 'radar',
+ ]);
+ $questionnaire = questionnaire::from_instanceid($questionnaire->id());
+
+ $this->assertTrue($questionnaire->feedback()->show_scores());
+ $this->assertSame('Hello
', $questionnaire->feedback()->notes());
+ $this->assertSame('radar', $questionnaire->feedback()->chart_type());
+ }
+
+ /**
+ * rendered_notes() returns '' when notes is empty without calling file_rewrite_pluginfile_urls.
+ */
+ public function test_rendered_notes_empty_short_circuits(): void {
+ $questionnaire = $this->setup_questionnaire();
+ $this->assertSame('', $questionnaire->feedback()->rendered_notes());
+ }
+
+ /**
+ * rendered_notes() returns the pluginfile-rewritten notes when set.
+ */
+ public function test_rendered_notes_rewrites_pluginfile_urls(): void {
+ $questionnaire = $this->setup_questionnaire();
+ $questionnaire->feedback()->update_settings([
+ 'feedbacknotes' => 'Plain text
',
+ ]);
+ $questionnaire = questionnaire::from_instanceid($questionnaire->id());
+
+ $rendered = $questionnaire->feedback()->rendered_notes();
+ $this->assertStringContainsString('Plain text', $rendered);
+ }
+
+ /**
+ * has_any_feedback_questions() is false on an empty questionnaire and true once a valid-feedback
+ * question (rate, yesno, slider) is added.
+ */
+ public function test_has_any_feedback_questions(): void {
+ $questionnaire = $this->setup_questionnaire();
+ $this->assertFalse($questionnaire->feedback()->has_any_feedback_questions());
+
+ $generator = $this->getDataGenerator()->get_plugin_generator('mod_questionnaire');
+ $generator->create_question(
+ $questionnaire,
+ [
+ 'surveyid' => $questionnaire->surveyid(),
+ 'name' => 'Q1',
+ 'typeid' => QUESYESNO,
+ 'content' => 'Y/N',
+ 'required' => 'y',
+ ]
+ );
+ $questionnaire = questionnaire::from_instanceid($questionnaire->id());
+ $this->assertTrue($questionnaire->feedback()->has_any_feedback_questions());
+ }
+
+ /**
+ * $q->feedback() returns the same instance on repeated calls (cached lazy accessor).
+ */
+ public function test_feedback_accessor_is_cached(): void {
+ $questionnaire = $this->setup_questionnaire();
+ $this->assertSame($questionnaire->feedback(), $questionnaire->feedback());
+ }
+
+ /**
+ * update_settings() persists allowlisted feedback fields onto the survey row.
+ */
+ public function test_update_settings_persists_allowlisted_fields(): void {
+ global $DB;
+ $questionnaire = $this->setup_questionnaire();
+
+ $result = $questionnaire->feedback()->update_settings([
+ 'feedbacksections' => 3,
+ 'feedbackscores' => 1,
+ 'feedbacknotes' => 'Notes
',
+ 'charttype' => 'bipolar',
+ ]);
+
+ $this->assertSame($questionnaire->surveyid(), $result);
+ $row = $DB->get_record('questionnaire_survey', ['id' => $questionnaire->surveyid()]);
+ $this->assertEquals(3, $row->feedbacksections);
+ $this->assertEquals(1, $row->feedbackscores);
+ $this->assertSame('Notes
', $row->feedbacknotes);
+ $this->assertSame('bipolar', $row->charttype);
+ }
+
+ /**
+ * update_settings() rejects fields outside the feedback allowlist via coding_exception.
+ */
+ public function test_update_settings_rejects_unknown_field(): void {
+ $questionnaire = $this->setup_questionnaire();
+
+ $this->expectException(\coding_exception::class);
+ $questionnaire->feedback()->update_settings(['title' => 'spoof']);
+ }
+
+ /**
+ * update_settings() also rejects survey-form metadata such as 'id' or 'sid'.
+ */
+ public function test_update_settings_rejects_survey_metadata(): void {
+ $questionnaire = $this->setup_questionnaire();
+
+ $this->expectException(\coding_exception::class);
+ $questionnaire->feedback()->update_settings(['id' => 999]);
+ }
+
+ /**
+ * ensure_first_section() creates a section when feedback is enabled and none exist.
+ */
+ public function test_ensure_first_section_creates_when_missing(): void {
+ global $DB;
+ $questionnaire = $this->setup_questionnaire();
+ $questionnaire->feedback()->update_settings(['feedbacksections' => 1]);
+ $surveyid = $questionnaire->surveyid();
+ $this->assertSame(0, $DB->count_records('questionnaire_fb_sections', ['surveyid' => $surveyid]));
+
+ $result = $questionnaire->feedback()->ensure_first_section();
+
+ $this->assertSame(0, $result); // Returns 0 because no section existed before this call.
+ $this->assertSame(1, $DB->count_records('questionnaire_fb_sections', ['surveyid' => $surveyid]));
+ }
+
+ /**
+ * ensure_first_section() is a no-op when feedback is disabled.
+ */
+ public function test_ensure_first_section_no_op_when_disabled(): void {
+ global $DB;
+ $questionnaire = $this->setup_questionnaire();
+
+ $result = $questionnaire->feedback()->ensure_first_section();
+
+ $this->assertSame(0, $result);
+ $this->assertSame(0, $DB->count_records(
+ 'questionnaire_fb_sections',
+ ['surveyid' => $questionnaire->surveyid()]
+ ));
+ }
+}
diff --git a/tests/local/feedback/section_test.php b/tests/local/feedback/section_test.php
new file mode 100644
index 000000000..4da776dc2
--- /dev/null
+++ b/tests/local/feedback/section_test.php
@@ -0,0 +1,242 @@
+.
+
+/**
+ * Unit tests for mod_questionnaire\local\feedback\section.
+ *
+ * @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\local\feedback;
+
+/**
+ * Unit tests for mod_questionnaire\local\feedback\section.
+ *
+ * @group mod_questionnaire
+ * @covers \mod_questionnaire\local\feedback\section
+ */
+final class section_test extends \advanced_testcase {
+ /**
+ * Build a sample feedback-band data object suitable for sectionfeedback::new_sectionfeedback().
+ *
+ * @param int $sectionid
+ * @param float $min
+ * @param float $max
+ * @return \stdClass
+ */
+ private function band(int $sectionid, float $min, float $max): \stdClass {
+ return (object)[
+ 'sectionid' => $sectionid,
+ 'feedbacklabel' => 'L',
+ 'feedbacktext' => 'T',
+ 'feedbacktextformat' => FORMAT_HTML,
+ 'minscore' => $min,
+ 'maxscore' => $max,
+ ];
+ }
+
+ /**
+ * new_section() inserts a new feedback-section row and returns an instance with section = max + 1.
+ */
+ public function test_new_section_increments_section_number(): void {
+ global $DB;
+ $this->resetAfterTest();
+ $sid = 5001;
+
+ $first = section::new_section($sid, 'First');
+ $this->assertGreaterThan(0, $first->id);
+ $this->assertSame(1, $first->section);
+ $this->assertSame('First', $first->sectionlabel);
+ $this->assertTrue($DB->record_exists('questionnaire_fb_sections', ['id' => $first->id]));
+
+ $second = section::new_section($sid, 'Second');
+ $this->assertSame(2, $second->section);
+ }
+
+ /**
+ * new_section() uses a default label when none is supplied.
+ */
+ public function test_new_section_default_label(): void {
+ $this->resetAfterTest();
+ $sid = 5002;
+ $section = section::new_section($sid);
+ $this->assertSame(get_string('feedbackdefaultlabel', 'questionnaire'), $section->sectionlabel);
+ }
+
+ /**
+ * decode_scorecalculation() returns an empty array for null/empty input.
+ */
+ public function test_decode_scorecalculation_empty(): void {
+ $this->assertSame([], section::decode_scorecalculation(null));
+ $this->assertSame([], section::decode_scorecalculation(''));
+ }
+
+ /**
+ * decode_scorecalculation() unserializes a valid score array.
+ */
+ public function test_decode_scorecalculation_valid(): void {
+ $encoded = serialize([10 => 1, 11 => 2]);
+ $this->assertSame([10 => 1, 11 => 2], section::decode_scorecalculation($encoded));
+ }
+
+ /**
+ * decode_scorecalculation() throws when an entry's score is non-numeric.
+ */
+ public function test_decode_scorecalculation_invalid(): void {
+ $this->expectException(\coding_exception::class);
+ section::decode_scorecalculation(serialize([10 => 'not-a-number']));
+ }
+
+ /**
+ * load_section() reads a section by id and attaches its feedback bands.
+ */
+ public function test_load_section_by_id(): void {
+ $this->resetAfterTest();
+ $sid = 5003;
+ $created = section::new_section($sid, 'lbl');
+
+ // Add two feedback bands.
+ sectionfeedback::new_sectionfeedback($this->band($created->id, 0, 50));
+ sectionfeedback::new_sectionfeedback($this->band($created->id, 50, 100));
+
+ $loaded = new section([], ['id' => $created->id]);
+ $this->assertSame($created->id, $loaded->id);
+ $this->assertSame($sid, $loaded->surveyid);
+ $this->assertCount(2, $loaded->sectionfeedback);
+ }
+
+ /**
+ * load_section() can find by surveyid + section number.
+ */
+ public function test_load_section_by_surveyid(): void {
+ $this->resetAfterTest();
+ $sid = 5004;
+ section::new_section($sid, 'first');
+ $second = section::new_section($sid, 'second');
+ sectionfeedback::new_sectionfeedback($this->band($second->id, 0, 100));
+
+ $loaded = new section([], ['surveyid' => $sid, 'sectionnum' => 2]);
+ $this->assertSame($second->id, $loaded->id);
+ $this->assertSame('second', $loaded->sectionlabel);
+ }
+
+ /**
+ * load_section() throws when nothing matches the supplied parameters.
+ */
+ public function test_load_section_missing_throws(): void {
+ $this->resetAfterTest();
+ $this->expectException(\invalid_parameter_exception::class);
+ new section([], ['id' => 99999]);
+ }
+
+ /**
+ * update() writes current in-memory state back to the row.
+ */
+ public function test_update_persists_changes(): void {
+ global $DB;
+ $this->resetAfterTest();
+ $sid = 5005;
+ $section = section::new_section($sid);
+ $section->sectionlabel = 'renamed';
+ $section->update();
+
+ $row = $DB->get_record('questionnaire_fb_sections', ['id' => $section->id]);
+ $this->assertSame('renamed', $row->sectionlabel);
+ }
+
+ /**
+ * delete_sectionfeedback() removes all feedback bands for the section.
+ */
+ public function test_delete_sectionfeedback_removes_bands(): void {
+ global $DB;
+ $this->resetAfterTest();
+ $sid = 5006;
+ $section = section::new_section($sid);
+ sectionfeedback::new_sectionfeedback($this->band($section->id, 0, 50));
+ sectionfeedback::new_sectionfeedback($this->band($section->id, 50, 100));
+
+ $this->assertEquals(2, $DB->count_records('questionnaire_feedback', ['sectionid' => $section->id]));
+ $section->delete_sectionfeedback();
+ $this->assertEquals(0, $DB->count_records('questionnaire_feedback', ['sectionid' => $section->id]));
+ $this->assertSame([], $section->sectionfeedback);
+ }
+
+ /**
+ * delete() removes the section row, its feedback bands, and resequences remaining sections.
+ */
+ public function test_delete_resequences(): void {
+ global $DB;
+ $this->resetAfterTest();
+ $sid = 5007;
+ $s1 = section::new_section($sid);
+ $s2 = section::new_section($sid);
+ $s3 = section::new_section($sid);
+ sectionfeedback::new_sectionfeedback($this->band($s2->id, 0, 100));
+
+ // Delete the middle section.
+ $s2->delete();
+
+ $this->assertFalse($DB->record_exists('questionnaire_fb_sections', ['id' => $s2->id]));
+ $this->assertEquals(0, $DB->count_records('questionnaire_feedback', ['sectionid' => $s2->id]));
+
+ $remaining = $DB->get_records('questionnaire_fb_sections', ['surveyid' => $sid], 'section ASC');
+ $sections = array_map(fn($r) => (int) $r->section, array_values($remaining));
+ $this->assertSame([1, 2], $sections);
+ }
+
+ /**
+ * load_sectionfeedback() registers a new band on this section and inserts its row.
+ */
+ public function test_load_sectionfeedback_creates_new_band(): void {
+ global $DB;
+ $this->resetAfterTest();
+ $sid = 5008;
+ $section = section::new_section($sid);
+
+ $data = (object)[
+ 'sectionid' => $section->id,
+ 'feedbacklabel' => 'L',
+ 'feedbacktext' => 'T',
+ 'feedbacktextformat' => FORMAT_HTML,
+ 'minscore' => 10,
+ 'maxscore' => 60,
+ ];
+ $newid = $section->load_sectionfeedback($data);
+
+ $this->assertGreaterThan(0, $newid);
+ $this->assertArrayHasKey($newid, $section->sectionfeedback);
+ $this->assertTrue($DB->record_exists('questionnaire_feedback', ['id' => $newid]));
+ }
+
+ /**
+ * set_new_scorecalculation() persists the encoded payload and keeps the in-memory state aligned.
+ */
+ public function test_set_new_scorecalculation_persists(): void {
+ global $DB;
+ $this->resetAfterTest();
+ $sid = 5009;
+ $section = section::new_section($sid);
+
+ $section->set_new_scorecalculation([10 => 1, 11 => 2]);
+
+ $stored = $DB->get_field('questionnaire_fb_sections', 'scorecalculation', ['id' => $section->id]);
+ $this->assertSame([10 => 1, 11 => 2], unserialize($stored));
+ $this->assertSame([10 => 1, 11 => 2], $section->scorecalculation);
+ }
+}
diff --git a/tests/local/feedback/sectionfeedback_test.php b/tests/local/feedback/sectionfeedback_test.php
new file mode 100644
index 000000000..6b109a0aa
--- /dev/null
+++ b/tests/local/feedback/sectionfeedback_test.php
@@ -0,0 +1,147 @@
+.
+
+/**
+ * Unit tests for mod_questionnaire\local\feedback\sectionfeedback.
+ *
+ * @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\local\feedback;
+
+/**
+ * Unit tests for mod_questionnaire\local\feedback\sectionfeedback.
+ *
+ * @group mod_questionnaire
+ * @covers \mod_questionnaire\local\feedback\sectionfeedback
+ */
+final class sectionfeedback_test extends \advanced_testcase {
+ /**
+ * Build a stdClass with the fields new_sectionfeedback expects.
+ *
+ * @param int $sectionid
+ * @param float $minscore
+ * @param float $maxscore
+ * @return \stdClass
+ */
+ private function sample_data(int $sectionid, float $minscore = 0.0, float $maxscore = 100.0): \stdClass {
+ return (object)[
+ 'sectionid' => $sectionid,
+ 'feedbacklabel' => 'label',
+ 'feedbacktext' => 'text',
+ 'feedbacktextformat' => FORMAT_HTML,
+ 'minscore' => $minscore,
+ 'maxscore' => $maxscore,
+ ];
+ }
+
+ /**
+ * The constructor with id = 0 leaves the object in its empty default state.
+ */
+ public function test_construct_empty(): void {
+ $sf = new sectionfeedback();
+ $this->assertSame(0, $sf->id);
+ $this->assertSame(0, $sf->sectionid);
+ }
+
+ /**
+ * Constructing with a stdClass record copies known fields without writing to the DB.
+ */
+ public function test_construct_with_record(): void {
+ global $DB;
+ $this->resetAfterTest();
+ $countbefore = $DB->count_records('questionnaire_feedback');
+
+ $rec = (object)[
+ 'id' => 0,
+ 'sectionid' => 10,
+ 'feedbacklabel' => 'lbl',
+ 'feedbacktext' => 'body',
+ 'feedbacktextformat' => FORMAT_HTML,
+ 'minscore' => 25.0,
+ 'maxscore' => 75.0,
+ ];
+ $sf = new sectionfeedback(0, $rec);
+
+ $this->assertSame(10, $sf->sectionid);
+ $this->assertSame('lbl', $sf->feedbacklabel);
+ $this->assertSame('body', $sf->feedbacktext);
+ $this->assertEquals(25.0, $sf->minscore);
+ $this->assertEquals(75.0, $sf->maxscore);
+ $this->assertSame($countbefore, $DB->count_records('questionnaire_feedback'));
+ }
+
+ /**
+ * Constructing with an existing id loads the row from the database.
+ */
+ public function test_construct_loads_existing_id(): void {
+ $this->resetAfterTest();
+ $created = sectionfeedback::new_sectionfeedback($this->sample_data(20));
+
+ $loaded = new sectionfeedback($created->id);
+ $this->assertEquals($created->id, $loaded->id);
+ $this->assertEquals(20, $loaded->sectionid);
+ $this->assertSame('label', $loaded->feedbacklabel);
+ }
+
+ /**
+ * Constructing with a non-existent id throws.
+ */
+ public function test_construct_unknown_id_throws(): void {
+ $this->resetAfterTest();
+ $this->expectException(\invalid_parameter_exception::class);
+ new sectionfeedback(99999);
+ }
+
+ /**
+ * new_sectionfeedback() persists a new row and returns an instance with a real id.
+ */
+ public function test_new_sectionfeedback_persists(): void {
+ global $DB;
+ $this->resetAfterTest();
+
+ $sf = sectionfeedback::new_sectionfeedback($this->sample_data(30, 10.0, 90.0));
+
+ $this->assertGreaterThan(0, $sf->id);
+ $row = $DB->get_record('questionnaire_feedback', ['id' => $sf->id]);
+ $this->assertNotEmpty($row);
+ $this->assertEquals(30, $row->sectionid);
+ $this->assertEquals(10.0, $row->minscore);
+ $this->assertEquals(90.0, $row->maxscore);
+ }
+
+ /**
+ * update() writes current in-memory state back to the row.
+ */
+ public function test_update_persists_changes(): void {
+ global $DB;
+ $this->resetAfterTest();
+
+ $sf = sectionfeedback::new_sectionfeedback($this->sample_data(40, 0.0, 100.0));
+ $sf->minscore = 33.0;
+ $sf->maxscore = 66.0;
+ $sf->feedbacktext = 'updated text';
+ $sf->update();
+
+ $row = $DB->get_record('questionnaire_feedback', ['id' => $sf->id]);
+ $this->assertEquals(33.0, $row->minscore);
+ $this->assertEquals(66.0, $row->maxscore);
+ $this->assertSame('updated text', $row->feedbacktext);
+ }
+}
diff --git a/tests/local/question/rate_test.php b/tests/local/question/rate_test.php
new file mode 100644
index 000000000..270a81225
--- /dev/null
+++ b/tests/local/question/rate_test.php
@@ -0,0 +1,171 @@
+.
+
+/**
+ * Canary tests for rate::question_survey_display / response_survey_display.
+ *
+ * These tests exist to guard the return-shape of the two big render methods
+ * before / after Phase E's method-length refactor. They assert on the
+ * populated $choicetags / $resptags objects (via reflection since both
+ * methods are protected), not the eventual mustache HTML.
+ *
+ * @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\local\question;
+
+use mod_questionnaire\local\response\response;
+
+/**
+ * Unit tests for mod_questionnaire\local\question\rate rendering methods.
+ *
+ * @group mod_questionnaire
+ * @covers \mod_questionnaire\local\question\rate::question_survey_display
+ * @covers \mod_questionnaire\local\question\rate::response_survey_display
+ */
+final class rate_test extends \advanced_testcase {
+ /**
+ * Build a rate question with the given precise value + choices + optional extradata (named degrees json).
+ *
+ * @param int $precise Rate scale type: 0 normal, 1 na, 2 no-dup, 3 osgood.
+ * @param array $choicecontents Plain-string content per choice.
+ * @param string $extradata JSON extradata for named degrees (empty for none).
+ * @return rate
+ */
+ private function build_rate_question(int $precise, array $choicecontents, string $extradata = ''): rate {
+ $generator = $this->getDataGenerator();
+ $course = $generator->create_course();
+ $plugingen = $generator->get_plugin_generator('mod_questionnaire');
+ $choicedata = [];
+ $i = 1;
+ foreach ($choicecontents as $content) {
+ $choicedata[] = (object)['content' => $content, 'value' => $i++];
+ }
+ $questiondata = [
+ 'content' => 'Rate these',
+ 'length' => 5,
+ 'precise' => $precise,
+ 'extradata' => $extradata,
+ ];
+ $questionnaire = $plugingen->create_test_questionnaire($course, QUESRATE, $questiondata, $choicedata);
+ $questions = $questionnaire->questions();
+ /** @var rate $question */
+ $question = reset($questions);
+ return $question;
+ }
+
+ /**
+ * Invoke a protected method by name via reflection.
+ *
+ * @param object $obj
+ * @param string $method
+ * @param array $args
+ * @return mixed
+ */
+ private function invoke(object $obj, string $method, array $args) {
+ $ref = new \ReflectionMethod($obj, $method);
+ $ref->setAccessible(true);
+ return $ref->invokeArgs($obj, $args);
+ }
+
+ /**
+ * A normal-scale rate question renders a header row with one column per rating length,
+ * and one row per choice.
+ */
+ public function test_question_survey_display_normal_scale_shape(): void {
+ $this->resetAfterTest();
+ $this->setAdminUser();
+ $question = $this->build_rate_question(0, ['One', 'Two', 'Three']);
+ $response = response::create_from_data([]);
+
+ $choicetags = $this->invoke($question, 'question_survey_display', [$response, '', false]);
+
+ $this->assertIsObject($choicetags);
+ $this->assertSame('Rate these', $choicetags->qelements['caption']);
+ $this->assertCount(5, $choicetags->qelements['headerrow']['cols']);
+ $this->assertCount(3, $choicetags->qelements['rows']);
+ }
+
+ /**
+ * An N/A rate question appends an N/A column to the header row.
+ */
+ public function test_question_survey_display_na_column_added_to_header(): void {
+ $this->resetAfterTest();
+ $this->setAdminUser();
+ $question = $this->build_rate_question(1, ['One', 'Two', 'Three']);
+ $response = response::create_from_data([]);
+
+ $choicetags = $this->invoke($question, 'question_survey_display', [$response, '', false]);
+
+ // 5 rating columns + 1 N/A column.
+ $this->assertCount(6, $choicetags->qelements['headerrow']['cols']);
+ $natext = get_string('notapplicable', 'questionnaire');
+ $lastcol = end($choicetags->qelements['headerrow']['cols']);
+ $this->assertSame($natext, $lastcol['coltext']);
+ }
+
+ /**
+ * response_survey_display shape mirrors the question shape: one header per length + one row per choice.
+ */
+ public function test_response_survey_display_normal_scale_shape(): void {
+ $this->resetAfterTest();
+ $this->setAdminUser();
+ $question = $this->build_rate_question(0, ['One', 'Two', 'Three']);
+ $response = response::create_from_data([]);
+
+ $resptags = $this->invoke($question, 'response_survey_display', [$response]);
+
+ $this->assertIsObject($resptags);
+ $this->assertCount(5, $resptags->headers);
+ $this->assertCount(3, $resptags->rows);
+ }
+
+ /**
+ * With an N/A column, response_survey_display appends the same N/A header.
+ */
+ public function test_response_survey_display_na_column_added_to_header(): void {
+ $this->resetAfterTest();
+ $this->setAdminUser();
+ $question = $this->build_rate_question(1, ['One', 'Two', 'Three']);
+ $response = response::create_from_data([]);
+
+ $resptags = $this->invoke($question, 'response_survey_display', [$response]);
+
+ $this->assertCount(6, $resptags->headers);
+ $natext = get_string('notapplicable', 'questionnaire');
+ $this->assertSame($natext, end($resptags->headers)->str);
+ }
+
+ /**
+ * Osgood scale sets $resptags->osgood = 1 and picks the correct sidecolwidth default (45%).
+ * The sidecolwidth branch depends on $maxndlen which is currently never mutated —
+ * this test locks in the "first branch" behavior in effect today.
+ */
+ public function test_response_survey_display_osgood_sets_flag_and_sidecolwidth(): void {
+ $this->resetAfterTest();
+ $this->setAdminUser();
+ $question = $this->build_rate_question(3, ['Cold|Hot', 'Wet|Dry']);
+ $response = response::create_from_data([]);
+
+ $resptags = $this->invoke($question, 'response_survey_display', [$response]);
+
+ $this->assertSame(1, $resptags->osgood);
+ $this->assertSame('45%', $resptags->sidecolwidth);
+ }
+}
diff --git a/tests/csvexport_test.php b/tests/local/report/csvexport_test.php
similarity index 91%
rename from tests/csvexport_test.php
rename to tests/local/report/csvexport_test.php
index 714f3da99..1a722aaa5 100644
--- a/tests/csvexport_test.php
+++ b/tests/local/report/csvexport_test.php
@@ -23,11 +23,12 @@
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
-namespace mod_questionnaire;
+namespace mod_questionnaire\local\report;
/**
* Unit tests for questionnaire_csvexport_test.
* @group mod_questionnaire
+ * @covers \mod_questionnaire\local\report\reporter::generate_csv
*/
final class csvexport_test extends \advanced_testcase {
public function setUp(): void {
@@ -60,8 +61,6 @@ private function get_csv_text(array $rows) {
/**
* Tests the CSV export.
- *
- * @covers \questionnaire::generate_csv
*/
public function test_csvexport(): void {
$this->resetAfterTest();
@@ -69,21 +68,17 @@ public function test_csvexport(): void {
$qdg = $dg->get_plugin_generator('mod_questionnaire');
$qdg->create_and_fully_populate(1, 5, 1, 1);
- // The following line simply.
$questionnaires = $qdg->questionnaires();
foreach ($questionnaires as $questionnaire) {
- [$course, $cm] = get_course_and_cm_from_instance($questionnaire->id, 'questionnaire', $questionnaire->course);
- $questionnaireinst = new \questionnaire($course, $cm, 0, $questionnaire);
-
// Test for only complete responses.
- $newoutput = $this->get_csv_text($questionnaireinst->generate_csv(0, '', '', 0, 0, 0));
+ $newoutput = $this->get_csv_text($questionnaire->reporter()->generate_csv(0, '', '', 0, 0, 0));
$this->assertEquals(count($newoutput), count($this->expected_complete_output()));
foreach ($newoutput as $key => $output) {
$this->assertEquals($this->expected_complete_output()[$key], $output);
}
// Test for all responses.
- $newoutput = $this->get_csv_text($questionnaireinst->generate_csv(0, '', '', 0, 0, 1));
+ $newoutput = $this->get_csv_text($questionnaire->reporter()->generate_csv(0, '', '', 0, 0, 1));
$this->assertEquals(count($newoutput), count($this->expected_incomplete_output()));
foreach ($newoutput as $key => $output) {
$this->assertEquals($this->expected_incomplete_output()[$key], $output);
@@ -93,8 +88,6 @@ public function test_csvexport(): void {
/**
* Tests the CSV export with identity fields and anonymous questionnaires.
- *
- * @covers \questionnaire::generate_csv
*/
public function test_csvexport_identity_fields(): void {
global $DB;
@@ -116,7 +109,7 @@ public function test_csvexport_identity_fields(): void {
$questionnaires = $qdg->questionnaires();
foreach ($questionnaires as $item) {
- [$course, $cm] = get_course_and_cm_from_instance($item->id, 'questionnaire', $item->course);
+ [$course, $cm] = get_course_and_cm_from_instance($item->id(), 'questionnaire', $item->courseid());
$this->do_test_csvexport_identity_fields($course, $cm, $user, $roleid, $profilefields, $item, false);
$this->do_test_csvexport_identity_fields($course, $cm, $user, $roleid, $profilefields, $item, true);
@@ -131,11 +124,8 @@ public function test_csvexport_identity_fields(): void {
* @param object $user
* @param int $roleid
* @param array $profilefields
- * @param object $item
+ * @param \mod_questionnaire\questionnaire $item
* @param bool $anonymous
- * @throws coding_exception
- * @throws dml_exception
- * @throws moodle_exception
*/
private function do_test_csvexport_identity_fields($course, $cm, $user, $roleid, $profilefields, $item, $anonymous): void {
global $DB;
@@ -143,7 +133,7 @@ private function do_test_csvexport_identity_fields($course, $cm, $user, $roleid,
if ($anonymous) {
// Make questionnaire anonymous.
$row = new \stdClass();
- $row->id = $item->id;
+ $row->id = $item->id();
$row->respondenttype = 'anonymous';
$DB->update_record('questionnaire', $row);
}
@@ -153,8 +143,8 @@ private function do_test_csvexport_identity_fields($course, $cm, $user, $roleid,
assign_capability('moodle/site:viewuseridentity', CAP_ALLOW, $roleid, $context);
// Generate CSV output.
- $questionnaire = new \questionnaire($course, $cm, $item->id);
- $output = $questionnaire->generate_csv(0, '', '', 0, 0, 1);
+ $questionnaire = \mod_questionnaire\questionnaire::from_cmid($cm->id);
+ $output = $questionnaire->reporter()->generate_csv(0, '', '', 0, 0, 1);
$this->assertNotNull($output);
$this->assertCount(3, $output);
diff --git a/tests/local/report/report_actions_test.php b/tests/local/report/report_actions_test.php
new file mode 100644
index 000000000..a7fcf158c
--- /dev/null
+++ b/tests/local/report/report_actions_test.php
@@ -0,0 +1,163 @@
+.
+
+/**
+ * Unit tests for mod_questionnaire\local\report\report_actions.
+ *
+ * @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\local\report;
+
+/**
+ * Unit tests for mod_questionnaire\local\report\report_actions.
+ *
+ * @group mod_questionnaire
+ * @covers \mod_questionnaire\local\report\report_actions
+ */
+final class report_actions_test extends \advanced_testcase {
+ /**
+ * delete_response removes the response record and fires response_deleted.
+ */
+ public function test_delete_response_removes_record_and_redirects(): void {
+ global $DB, $PAGE;
+ $this->resetAfterTest();
+ $this->setAdminUser();
+
+ $course = $this->getDataGenerator()->create_course();
+ $questionnaire = $this->getDataGenerator()
+ ->get_plugin_generator('mod_questionnaire')
+ ->create_instance(['course' => $course->id]);
+ $PAGE->set_context($questionnaire->context());
+
+ $rid = $DB->insert_record('questionnaire_response', (object)[
+ 'questionnaireid' => $questionnaire->id(),
+ 'userid' => 2,
+ 'submitted' => time(),
+ 'complete' => 'y',
+ 'grade' => 0,
+ ]);
+
+ $renderer = $PAGE->get_renderer('mod_questionnaire');
+ $sink = $this->redirectEvents();
+ try {
+ (new report_actions($questionnaire, $renderer))->delete_response($rid);
+ $this->fail('redirect() should have terminated the call');
+ } catch (\moodle_exception $e) {
+ // Under PHPUnit, redirect() throws; the response should still have been deleted first.
+ $this->assertFalse($DB->record_exists('questionnaire_response', ['id' => $rid]));
+ }
+
+ $events = array_filter(
+ $sink->get_events(),
+ fn($e) => $e instanceof \mod_questionnaire\event\response_deleted
+ );
+ $this->assertCount(1, $events);
+ }
+
+ /**
+ * delete_response throws invalidresponserecord when the rid does not exist.
+ */
+ public function test_delete_response_throws_for_missing_rid(): void {
+ global $PAGE;
+ $this->resetAfterTest();
+ $this->setAdminUser();
+
+ $course = $this->getDataGenerator()->create_course();
+ $questionnaire = $this->getDataGenerator()
+ ->get_plugin_generator('mod_questionnaire')
+ ->create_instance(['course' => $course->id]);
+ $PAGE->set_context($questionnaire->context());
+
+ $renderer = $PAGE->get_renderer('mod_questionnaire');
+ $this->expectException(\moodle_exception::class);
+ $this->expectExceptionMessageMatches('/invalidresponserecord|Invalid response record/i');
+ (new report_actions($questionnaire, $renderer))->delete_response(99999999);
+ }
+
+ /**
+ * delete_all_responses removes every passed response and fires all_responses_deleted.
+ */
+ public function test_delete_all_responses_removes_records_and_redirects(): void {
+ global $DB, $PAGE;
+ $this->resetAfterTest();
+ $this->setAdminUser();
+
+ $course = $this->getDataGenerator()->create_course();
+ $questionnaire = $this->getDataGenerator()
+ ->get_plugin_generator('mod_questionnaire')
+ ->create_instance(['course' => $course->id]);
+ $PAGE->set_context($questionnaire->context());
+
+ $ridone = $DB->insert_record('questionnaire_response', (object)[
+ 'questionnaireid' => $questionnaire->id(),
+ 'userid' => 2,
+ 'submitted' => time(),
+ 'complete' => 'y',
+ 'grade' => 0,
+ ]);
+ $ridtwo = $DB->insert_record('questionnaire_response', (object)[
+ 'questionnaireid' => $questionnaire->id(),
+ 'userid' => 3,
+ 'submitted' => time(),
+ 'complete' => 'y',
+ 'grade' => 0,
+ ]);
+
+ $resps = $questionnaire->get_responses();
+ $this->assertCount(2, $resps);
+
+ $renderer = $PAGE->get_renderer('mod_questionnaire');
+ $sink = $this->redirectEvents();
+ try {
+ (new report_actions($questionnaire, $renderer))
+ ->delete_all_responses(0, 0, $resps);
+ $this->fail('redirect() should have terminated the call');
+ } catch (\moodle_exception $e) {
+ $this->assertFalse($DB->record_exists('questionnaire_response', ['id' => $ridone]));
+ $this->assertFalse($DB->record_exists('questionnaire_response', ['id' => $ridtwo]));
+ }
+
+ $events = array_filter(
+ $sink->get_events(),
+ fn($e) => $e instanceof \mod_questionnaire\event\all_responses_deleted
+ );
+ $this->assertCount(1, $events);
+ }
+
+ /**
+ * delete_all_responses throws couldnotdelresp when there are no responses to delete.
+ */
+ public function test_delete_all_responses_throws_when_empty(): void {
+ global $PAGE;
+ $this->resetAfterTest();
+ $this->setAdminUser();
+
+ $course = $this->getDataGenerator()->create_course();
+ $questionnaire = $this->getDataGenerator()
+ ->get_plugin_generator('mod_questionnaire')
+ ->create_instance(['course' => $course->id]);
+ $PAGE->set_context($questionnaire->context());
+
+ $renderer = $PAGE->get_renderer('mod_questionnaire');
+ $this->expectException(\moodle_exception::class);
+ (new report_actions($questionnaire, $renderer))
+ ->delete_all_responses(0, 0, []);
+ }
+}
diff --git a/tests/local/report/report_downloader_test.php b/tests/local/report/report_downloader_test.php
new file mode 100644
index 000000000..1670b9a8c
--- /dev/null
+++ b/tests/local/report/report_downloader_test.php
@@ -0,0 +1,179 @@
+.
+
+/**
+ * Unit tests for mod_questionnaire\local\report\report_downloader.
+ *
+ * @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\local\report;
+
+use mod_questionnaire\output\reportpage;
+
+/**
+ * Unit tests for mod_questionnaire\local\report\report_downloader.
+ *
+ * @group mod_questionnaire
+ * @covers \mod_questionnaire\local\report\report_downloader
+ */
+final class report_downloader_test extends \advanced_testcase {
+ /**
+ * show_download_options enforces the downloadresponses capability.
+ */
+ public function test_show_download_options_requires_capability(): void {
+ global $PAGE;
+ $this->resetAfterTest();
+
+ $course = $this->getDataGenerator()->create_course();
+ $questionnaire = $this->getDataGenerator()
+ ->get_plugin_generator('mod_questionnaire')
+ ->create_instance(['course' => $course->id]);
+ $PAGE->set_context($questionnaire->context());
+
+ // A freshly-created student has no downloadresponses cap.
+ $student = $this->getDataGenerator()->create_and_enrol($course, 'student');
+ $this->setUser($student);
+
+ $renderer = $PAGE->get_renderer('mod_questionnaire');
+ $this->expectException(\required_capability_exception::class);
+ (new report_downloader($questionnaire, $renderer))
+ ->show_download_options(new reportpage(), 0, 0, [], 0);
+ }
+
+ /**
+ * download_responses enforces the downloadresponses capability.
+ */
+ public function test_download_responses_requires_capability(): void {
+ global $PAGE;
+ $this->resetAfterTest();
+
+ $course = $this->getDataGenerator()->create_course();
+ $questionnaire = $this->getDataGenerator()
+ ->get_plugin_generator('mod_questionnaire')
+ ->create_instance(['course' => $course->id]);
+ $PAGE->set_context($questionnaire->context());
+
+ $student = $this->getDataGenerator()->create_and_enrol($course, 'student');
+ $this->setUser($student);
+
+ $renderer = $PAGE->get_renderer('mod_questionnaire');
+ $this->expectException(\required_capability_exception::class);
+ (new report_downloader($questionnaire, $renderer))
+ ->download_responses(new reportpage(), 0, 0);
+ }
+
+ /**
+ * download_responses redirects to dwnpg when "email report" is selected but no
+ * recipients are specified (allowemailreporting off, no roles, no extras).
+ */
+ public function test_download_responses_redirects_when_no_emails(): void {
+ global $PAGE;
+ $this->resetAfterTest();
+ $this->setAdminUser();
+
+ $qdg = $this->getDataGenerator()->get_plugin_generator('mod_questionnaire');
+ $qdg->create_and_fully_populate(1, 1, 1, 1);
+ $questionnaires = $qdg->questionnaires();
+ $questionnaire = reset($questionnaires);
+ $PAGE->set_context($questionnaire->context());
+
+ // Force the email branch with no recipients and no allowemailreporting config.
+ $_POST['emailreport'] = 'send';
+ set_config('allowemailreporting', 0, 'questionnaire');
+
+ $renderer = $PAGE->get_renderer('mod_questionnaire');
+ $this->expectException(\moodle_exception::class);
+ try {
+ (new report_downloader($questionnaire, $renderer))
+ ->download_responses(new reportpage(), 0, 0);
+ } finally {
+ unset($_POST['emailreport']);
+ }
+ }
+
+ /**
+ * stream_and_email() emails the dataformat file to each user, redirects, and cleans up
+ * the temp file. Invoked via reflection because the method is private.
+ *
+ * Runs in a separate process so the test runner's prior output doesn't trip the
+ * header_remove() / send_http_headers() calls.
+ */
+ #[\PHPUnit\Framework\Attributes\RunInSeparateProcess]
+ #[\PHPUnit\Framework\Attributes\PreserveGlobalState(false)]
+ public function test_stream_and_email_dispatches_attachment_and_redirects(): void {
+ global $PAGE;
+ $this->resetAfterTest();
+ $this->setAdminUser();
+
+ $course = $this->getDataGenerator()->create_course();
+ $questionnaire = $this->getDataGenerator()
+ ->get_plugin_generator('mod_questionnaire')
+ ->create_instance(['course' => $course->id]);
+ $PAGE->set_context($questionnaire->context());
+ $renderer = $PAGE->get_renderer('mod_questionnaire');
+ $downloader = new report_downloader($questionnaire, $renderer);
+
+ $user = $this->getDataGenerator()->create_user(['email' => 'rcpt@example.org']);
+
+ $sink = $this->redirectEmails();
+
+ $iterator = new \ArrayIterator([
+ ['col1' => 'row1a', 'col2' => 'row1b'],
+ ['col1' => 'row2a', 'col2' => 'row2b'],
+ ]);
+
+ $method = new \ReflectionMethod($downloader, 'stream_and_email');
+ $method->setAccessible(true);
+
+ ob_start();
+ try {
+ $method->invoke(
+ $downloader,
+ 'qreport',
+ 'csv',
+ ['col1' => 'Col 1', 'col2' => 'Col 2'],
+ $iterator,
+ [$user],
+ ['extra@example.org'],
+ new \moodle_url('/mod/questionnaire/report.php', ['instance' => $questionnaire->id()])
+ );
+ $output = ob_get_clean();
+ } catch (\Throwable $e) {
+ ob_end_clean();
+ throw $e;
+ }
+
+ $messages = $sink->get_messages();
+ $sink->close();
+
+ // One email per enrolled user + one per extra address.
+ $this->assertCount(2, $messages);
+ $recipients = array_map(fn($m) => $m->to, $messages);
+ $this->assertContains('rcpt@example.org', $recipients);
+ $this->assertContains('extra@example.org', $recipients);
+
+ // Redirect message rendered (containing the configured "emailssent" lang string).
+ $this->assertStringContainsString(get_string('emailssent', 'questionnaire'), $output);
+
+ // Temp file cleaned up.
+ $tempfiles = glob(make_temp_directory('mod_questionnaire') . '/qreport*');
+ $this->assertEmpty($tempfiles);
+ }
+}
diff --git a/tests/local/report/report_view_builder_test.php b/tests/local/report/report_view_builder_test.php
new file mode 100644
index 000000000..98453e448
--- /dev/null
+++ b/tests/local/report/report_view_builder_test.php
@@ -0,0 +1,166 @@
+.
+
+/**
+ * Unit tests for mod_questionnaire\local\report\report_view_builder.
+ *
+ * @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\local\report;
+
+use mod_questionnaire\output\previewpage;
+use mod_questionnaire\questionnaire;
+
+/**
+ * Unit tests for mod_questionnaire\local\report\report_view_builder.
+ *
+ * @group mod_questionnaire
+ * @covers \mod_questionnaire\local\report\report_view_builder
+ */
+final class report_view_builder_test extends \advanced_testcase {
+ /**
+ * Build a course + questionnaire with one yes/no question.
+ *
+ * @return array [questionnaire, student id]
+ */
+ private function build_fixture(): array {
+ global $DB;
+ $generator = $this->getDataGenerator();
+ $course = $generator->create_course();
+ $plugin = $generator->get_plugin_generator('mod_questionnaire');
+ $instance = $plugin->create_test_questionnaire(
+ $course,
+ QUESYESNO,
+ ['content' => 'Yes or no?'],
+ []
+ );
+
+ $student = $generator->create_user();
+ $studentrole = $DB->get_record('role', ['shortname' => 'student'], '*', MUST_EXIST);
+ $generator->enrol_user($student->id, $course->id, $studentrole->id);
+
+ return [$instance, (int) $student->id];
+ }
+
+ /**
+ * Read the data backing a previewpage so tests can assert on the keys the renderer wrote.
+ *
+ * @param previewpage $page
+ * @return \stdClass
+ */
+ private function page_data(previewpage $page): \stdClass {
+ global $PAGE;
+ return $page->export_for_template($PAGE->get_renderer('mod_questionnaire'));
+ }
+
+ /**
+ * build_print_view() with rid = 0 populates the survey form skeleton on the page.
+ */
+ public function test_build_print_view_blank_renders_form(): void {
+ global $PAGE;
+ $this->resetAfterTest();
+ $this->setAdminUser();
+
+ [$instance, ] = $this->build_fixture();
+
+ $renderer = $PAGE->get_renderer('mod_questionnaire');
+ $page = new previewpage();
+ (new report_view_builder($renderer, $page))
+ ->build_print_view($instance, $instance->courseid(), '', 'preview', 0, true);
+
+ $data = $this->page_data($page);
+ $this->assertObjectHasProperty('formstart', $data);
+ $this->assertObjectHasProperty('questions', $data);
+ }
+
+ /**
+ * build_print_view() in preview mode (not blank) appends the preview-submit form end.
+ */
+ public function test_build_print_view_preview_adds_formend(): void {
+ global $PAGE;
+ $this->resetAfterTest();
+ $this->setAdminUser();
+
+ [$instance, ] = $this->build_fixture();
+
+ $renderer = $PAGE->get_renderer('mod_questionnaire');
+ $page = new previewpage();
+ (new report_view_builder($renderer, $page))
+ ->build_print_view($instance, $instance->courseid(), '', 'preview', 0, false);
+
+ $data = $this->page_data($page);
+ $this->assertObjectHasProperty('formend', $data);
+ }
+
+ /**
+ * build_print_view() writes the survey title via the shared header helper.
+ */
+ public function test_build_print_view_writes_title(): void {
+ global $DB, $PAGE;
+ $this->resetAfterTest();
+ $this->setAdminUser();
+
+ [$instance, ] = $this->build_fixture();
+ $DB->set_field(
+ 'questionnaire_survey',
+ 'title',
+ 'Print Preview Title',
+ ['id' => $instance->surveyid()]
+ );
+ $instance = questionnaire::from_instanceid($instance->id());
+
+ $renderer = $PAGE->get_renderer('mod_questionnaire');
+ $page = new previewpage();
+ (new report_view_builder($renderer, $page))
+ ->build_print_view($instance, $instance->courseid(), '', 'preview', 0, true);
+
+ $data = $this->page_data($page);
+ $this->assertObjectHasProperty('title', $data);
+ $this->assertStringContainsString('Print Preview Title', $data->title);
+ }
+
+ /**
+ * render_response() populates the responses key for an existing response.
+ */
+ public function test_render_response_populates_responses(): void {
+ global $DB, $PAGE;
+ $this->resetAfterTest();
+ $this->setAdminUser();
+
+ [$instance, $studentid] = $this->build_fixture();
+ $rid = $DB->insert_record('questionnaire_response', (object)[
+ 'questionnaireid' => $instance->id(),
+ 'userid' => $studentid,
+ 'submitted' => time(),
+ 'complete' => 'y',
+ 'grade' => 0,
+ ]);
+
+ $renderer = $PAGE->get_renderer('mod_questionnaire');
+ $page = new previewpage();
+ (new report_view_builder($renderer, $page))
+ ->render_response($instance, $rid, 'print');
+
+ // The 'responses' key is only present when there is at least one renderable question;
+ // even without a stored answer the YES/NO question renders an empty response row.
+ $data = $this->page_data($page);
+ $this->assertObjectHasProperty('responses', $data);
+ }
+}
diff --git a/tests/local/report/report_viewer_test.php b/tests/local/report/report_viewer_test.php
new file mode 100644
index 000000000..e9124e1e1
--- /dev/null
+++ b/tests/local/report/report_viewer_test.php
@@ -0,0 +1,146 @@
+.
+
+/**
+ * Unit tests for mod_questionnaire\local\report\report_viewer.
+ *
+ * @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\local\report;
+
+use mod_questionnaire\output\reportpage;
+use mod_questionnaire\questionnaire;
+
+/**
+ * Unit tests for mod_questionnaire\local\report\report_viewer.
+ *
+ * @group mod_questionnaire
+ * @covers \mod_questionnaire\local\report\report_viewer
+ */
+final class report_viewer_test extends \advanced_testcase {
+ /**
+ * Build a course + plain questionnaire and return both the domain object
+ * and a moodle_url pointing at the report page.
+ *
+ * @return array [questionnaire, moodle_url]
+ */
+ private function setup_fixture(): array {
+ global $PAGE;
+ $course = $this->getDataGenerator()->create_course();
+ $questionnaire = $this->getDataGenerator()
+ ->get_plugin_generator('mod_questionnaire')
+ ->create_instance(['course' => $course->id]);
+ $PAGE->set_context($questionnaire->context());
+ $url = new \moodle_url('/mod/questionnaire/report.php');
+
+ return [$questionnaire, $url];
+ }
+
+ /**
+ * Default response-status map used by the report.php caller (mirrors what the
+ * production entry script supplies).
+ *
+ * @return array
+ */
+ private function default_responsestatus(): array {
+ return [
+ 'y' => get_string('fullsubmissions', 'questionnaire'),
+ '0' => get_string('allresponses', 'questionnaire'),
+ 'n' => get_string('responsesnotsubmitted', 'questionnaire'),
+ ];
+ }
+
+ /**
+ * view_all_responses() throws nopermissions when the user has neither
+ * readallresponses nor readallresponseanytime.
+ */
+ public function test_view_all_responses_requires_capability(): void {
+ global $PAGE;
+ $this->resetAfterTest();
+ $this->setAdminUser();
+
+ [$questionnaire, $url] = $this->setup_fixture();
+
+ // Use an unenrolled user — no role assignments means no caps.
+ $user = $this->getDataGenerator()->create_user();
+ $this->setUser($user);
+
+ $renderer = $PAGE->get_renderer('mod_questionnaire');
+ $this->expectException(\moodle_exception::class);
+ $this->expectExceptionMessageMatches('/permission|nopermissions/i');
+ ob_start();
+ try {
+ (new report_viewer($questionnaire, $renderer))->view_all_responses(
+ new reportpage(),
+ 'vall',
+ 'html',
+ $url,
+ 0,
+ 0,
+ [],
+ [],
+ 'default',
+ '0',
+ $this->default_responsestatus()
+ );
+ } finally {
+ ob_end_clean();
+ }
+ }
+
+ /**
+ * view_individual_response() throws surveyowner when the survey is owned by
+ * a different course than the questionnaire instance.
+ */
+ public function test_view_individual_response_throws_when_survey_not_owned_by_course(): void {
+ global $DB, $PAGE;
+ $this->resetAfterTest();
+ $this->setAdminUser();
+
+ [$questionnaire, $url] = $this->setup_fixture();
+
+ // Point the survey at a different course so owning_courseid() != questionnaire->course->id.
+ $othercourse = $this->getDataGenerator()->create_course();
+ $DB->set_field(
+ 'questionnaire_survey',
+ 'courseid',
+ $othercourse->id,
+ ['id' => $questionnaire->surveyid()]
+ );
+ $questionnaire = questionnaire::from_instanceid($questionnaire->id());
+
+ $renderer = $PAGE->get_renderer('mod_questionnaire');
+ $this->expectException(\moodle_exception::class);
+ $this->expectExceptionMessageMatches('/owner|surveyowner/i');
+ (new report_viewer($questionnaire, $renderer))->view_individual_response(
+ new reportpage(),
+ 'html',
+ $url,
+ 0,
+ 0,
+ [],
+ false,
+ false,
+ false,
+ '0',
+ $this->default_responsestatus()
+ );
+ }
+}
diff --git a/tests/local/report/reporter_test.php b/tests/local/report/reporter_test.php
new file mode 100644
index 000000000..328975eda
--- /dev/null
+++ b/tests/local/report/reporter_test.php
@@ -0,0 +1,349 @@
+.
+
+/**
+ * Unit tests for mod_questionnaire\local\report\reporter.
+ *
+ * @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\local\report;
+
+use mod_questionnaire\output\reportpage;
+use mod_questionnaire\questionnaire;
+
+/**
+ * Unit tests for mod_questionnaire\local\report\reporter.
+ *
+ * @group mod_questionnaire
+ * @covers \mod_questionnaire\local\report\reporter::add_user_responses
+ * @covers \mod_questionnaire\local\report\reporter::view_all_responses
+ * @covers \mod_questionnaire\local\report\reporter::view_response
+ * @covers \mod_questionnaire\local\report\reporter::survey_results
+ * @covers \mod_questionnaire\local\report\reporter::survey_results_navbar_alpha
+ * @covers \mod_questionnaire\local\report\reporter::survey_results_navbar_student
+ */
+final class reporter_test extends \advanced_testcase {
+ /**
+ * Build a course + questionnaire with one yes/no question and one enrolled
+ * student, and return the questionnaire + student id.
+ *
+ * @return array [questionnaire, int studentid]
+ */
+ private function setup_fixture(): array {
+ global $PAGE;
+ $this->resetAfterTest();
+ $this->setAdminUser();
+
+ $course = $this->getDataGenerator()->create_course();
+ $plugin = $this->getDataGenerator()->get_plugin_generator('mod_questionnaire');
+ $questionnaire = $plugin->create_test_questionnaire(
+ $course,
+ QUESYESNO,
+ ['content' => 'Yes or no?'],
+ []
+ );
+
+ $student = $this->getDataGenerator()->create_user();
+ $this->getDataGenerator()->enrol_user($student->id, $course->id, 'student');
+
+ $PAGE->set_context($questionnaire->context());
+
+ return [$questionnaire, (int) $student->id];
+ }
+
+ /**
+ * Seed a complete response from the given user on the given questionnaire.
+ *
+ * @param questionnaire $questionnaire
+ * @param int $userid
+ * @return int Response id.
+ */
+ private function seed_complete_response(questionnaire $questionnaire, int $userid): int {
+ $plugin = $this->getDataGenerator()->get_plugin_generator('mod_questionnaire');
+ $resp = $plugin->generate_response(
+ $questionnaire,
+ $questionnaire->questions(),
+ $userid,
+ true
+ );
+ // The generate_response helper returns the array form of the response row.
+ return (int) $resp['id'];
+ }
+
+ /**
+ * Build a reporter with a renderer + page wired up for the page-side methods.
+ *
+ * @param questionnaire $questionnaire
+ * @return array [reporter, reportpage]
+ */
+ private function build_reporter(questionnaire $questionnaire): array {
+ global $PAGE;
+ $renderer = $PAGE->get_renderer('mod_questionnaire');
+ $page = new reportpage();
+ return [new reporter($questionnaire, $renderer, $page), $page];
+ }
+
+ /**
+ * add_user_responses() loads the user's responses into the shared collection.
+ */
+ public function test_add_user_responses_loads_user_responses(): void {
+ [$questionnaire, $studentid] = $this->setup_fixture();
+ $rid = $this->seed_complete_response($questionnaire, $studentid);
+
+ // Reload questionnaire — the responses collection caches per-instance state.
+ $questionnaire = questionnaire::from_instanceid($questionnaire->id());
+ [$reporter] = $this->build_reporter($questionnaire);
+
+ $this->assertSame([], $questionnaire->responses()->get_loaded_responses());
+ $reporter->add_user_responses($studentid);
+ $loaded = $questionnaire->responses()->get_loaded_responses();
+ $this->assertArrayHasKey($rid, $loaded);
+ }
+
+ /**
+ * view_all_responses() renders the seeded response data into the responses slot.
+ */
+ public function test_view_all_responses_renders_loaded_responses(): void {
+ [$questionnaire, $studentid] = $this->setup_fixture();
+ $this->seed_complete_response($questionnaire, $studentid);
+
+ $questionnaire = questionnaire::from_instanceid($questionnaire->id());
+ [$reporter, $page] = $this->build_reporter($questionnaire);
+ $reporter->add_user_responses($studentid);
+ $reporter->view_all_responses();
+
+ global $PAGE;
+ $data = $page->export_for_template($PAGE->get_renderer('mod_questionnaire'));
+ $this->assertObjectHasProperty('responses', $data);
+ $this->assertNotEmpty($data->responses);
+ }
+
+ /**
+ * view_all_responses() emits the 'noresponses' placeholder when nothing was loaded.
+ */
+ public function test_view_all_responses_renders_placeholder_when_empty(): void {
+ [$questionnaire] = $this->setup_fixture();
+ [$reporter, $page] = $this->build_reporter($questionnaire);
+
+ $reporter->view_all_responses();
+
+ global $PAGE;
+ $data = $page->export_for_template($PAGE->get_renderer('mod_questionnaire'));
+ $this->assertObjectHasProperty('responses', $data);
+ $this->assertStringContainsString(
+ get_string('noresponses', 'questionnaire'),
+ implode('', (array) $data->responses)
+ );
+ }
+
+ /**
+ * view_response() renders the single response into the responses slot.
+ */
+ public function test_view_response_renders_single_response(): void {
+ [$questionnaire, $studentid] = $this->setup_fixture();
+ $rid = $this->seed_complete_response($questionnaire, $studentid);
+
+ $questionnaire = questionnaire::from_instanceid($questionnaire->id());
+ [$reporter, $page] = $this->build_reporter($questionnaire);
+
+ $reporter->view_response($rid, '', [$rid => null]);
+
+ global $PAGE;
+ $data = $page->export_for_template($PAGE->get_renderer('mod_questionnaire'));
+ $this->assertObjectHasProperty('responses', $data);
+ $this->assertNotEmpty($data->responses);
+ }
+
+ /**
+ * survey_results() short-circuits silently when the survey has no questions.
+ */
+ public function test_survey_results_no_questions_short_circuits(): void {
+ [$questionnaire] = $this->setup_fixture();
+ // Delete the single question so questions() returns empty.
+ global $DB;
+ $DB->delete_records('questionnaire_question', ['surveyid' => $questionnaire->surveyid()]);
+ $questionnaire = questionnaire::from_instanceid($questionnaire->id());
+ [$reporter, $page] = $this->build_reporter($questionnaire);
+
+ $reporter->survey_results();
+
+ global $PAGE;
+ $data = $page->export_for_template($PAGE->get_renderer('mod_questionnaire'));
+ // No respondentinfo notification produced when there are no questions to report on.
+ $this->assertObjectNotHasProperty('respondentinfo', $data);
+ }
+
+ /**
+ * survey_results() emits the 'noresponses' notification when responses are absent.
+ */
+ public function test_survey_results_no_responses_emits_notification(): void {
+ [$questionnaire] = $this->setup_fixture();
+ [$reporter, $page] = $this->build_reporter($questionnaire);
+
+ $reporter->survey_results();
+
+ global $PAGE;
+ $data = $page->export_for_template($PAGE->get_renderer('mod_questionnaire'));
+ $this->assertObjectHasProperty('respondentinfo', $data);
+ $this->assertStringContainsString(
+ get_string('noresponses', 'questionnaire'),
+ $data->respondentinfo
+ );
+ }
+
+ /**
+ * survey_results_navbar_alpha() short-circuits silently when no responses exist.
+ */
+ public function test_survey_results_navbar_alpha_short_circuits_without_responses(): void {
+ [$questionnaire] = $this->setup_fixture();
+ [$reporter, $page] = $this->build_reporter($questionnaire);
+
+ $reporter->survey_results_navbar_alpha(0, 0, false);
+
+ global $PAGE;
+ $data = $page->export_for_template($PAGE->get_renderer('mod_questionnaire'));
+ $this->assertObjectNotHasProperty('navigationbar', $data);
+ $this->assertObjectNotHasProperty('responses', $data);
+ }
+
+ /**
+ * survey_results_navbar_alpha() with byresponse=false adds a prev/next navigation bar
+ * to the page when multiple responses exist.
+ */
+ public function test_survey_results_navbar_alpha_writes_navigationbar(): void {
+ [$questionnaire, $studentid] = $this->setup_fixture();
+ // Two extra students so there's a prev and next to navigate.
+ $extras = [];
+ $extras[] = $studentid;
+ $extras[] = (int) $this->getDataGenerator()->create_user()->id;
+ $extras[] = (int) $this->getDataGenerator()->create_user()->id;
+ foreach ([$extras[1], $extras[2]] as $uid) {
+ $this->getDataGenerator()->enrol_user($uid, $questionnaire->course()->id, 'student');
+ }
+ $rids = [];
+ foreach ($extras as $uid) {
+ $rids[] = $this->seed_complete_response($questionnaire, $uid);
+ }
+
+ $questionnaire = questionnaire::from_instanceid($questionnaire->id());
+ [$reporter, $page] = $this->build_reporter($questionnaire);
+
+ // Navigate from the middle response.
+ $reporter->survey_results_navbar_alpha($rids[1], 0, false);
+
+ global $PAGE;
+ $data = $page->export_for_template($PAGE->get_renderer('mod_questionnaire'));
+ $this->assertObjectHasProperty('navigationbar', $data);
+ }
+
+ /**
+ * survey_results_navbar_alpha() with byresponse=true emits a respondents-list rather
+ * than a navigation bar.
+ */
+ public function test_survey_results_navbar_alpha_byresponse_emits_respondents(): void {
+ [$questionnaire, $studentid] = $this->setup_fixture();
+ $rid = $this->seed_complete_response($questionnaire, $studentid);
+
+ $questionnaire = questionnaire::from_instanceid($questionnaire->id());
+ [$reporter, $page] = $this->build_reporter($questionnaire);
+
+ $reporter->survey_results_navbar_alpha($rid, 0, true);
+
+ global $PAGE;
+ $data = $page->export_for_template($PAGE->get_renderer('mod_questionnaire'));
+ $this->assertObjectHasProperty('responses', $data);
+ $this->assertObjectNotHasProperty('navigationbar', $data);
+ }
+
+ /**
+ * survey_results_navbar_student() writes the nav into both navigationbar slots
+ * when the student has multiple responses.
+ */
+ public function test_survey_results_navbar_student_writes_both_navigationbars(): void {
+ [$questionnaire, $studentid] = $this->setup_fixture();
+ // Multiple responses by the same student.
+ $rid1 = $this->seed_complete_response($questionnaire, $studentid);
+ $rid2 = $this->seed_complete_response($questionnaire, $studentid);
+
+ $questionnaire = questionnaire::from_instanceid($questionnaire->id());
+ [$reporter, $page] = $this->build_reporter($questionnaire);
+
+ $resps = $questionnaire->get_responses($studentid);
+ $reporter->survey_results_navbar_student(
+ $rid1,
+ $studentid,
+ $questionnaire->id(),
+ $resps,
+ 'myreport'
+ );
+
+ global $PAGE;
+ $data = $page->export_for_template($PAGE->get_renderer('mod_questionnaire'));
+ $this->assertObjectHasProperty('navigationbar', $data);
+ $this->assertObjectHasProperty('bottomnavigationbar', $data);
+ }
+
+ /**
+ * survey_results_navbar_student() in report mode includes the survey id in nav URLs.
+ */
+ public function test_survey_results_navbar_student_report_mode_includes_sid(): void {
+ [$questionnaire, $studentid] = $this->setup_fixture();
+ $rid1 = $this->seed_complete_response($questionnaire, $studentid);
+ $rid2 = $this->seed_complete_response($questionnaire, $studentid);
+
+ $questionnaire = questionnaire::from_instanceid($questionnaire->id());
+ [$reporter, $page] = $this->build_reporter($questionnaire);
+
+ $resps = $questionnaire->get_responses($studentid);
+ $reporter->survey_results_navbar_student(
+ $rid1,
+ $studentid,
+ $questionnaire->id(),
+ $resps,
+ 'report',
+ (string) $questionnaire->surveyid()
+ );
+
+ global $PAGE;
+ $data = $page->export_for_template($PAGE->get_renderer('mod_questionnaire'));
+ $this->assertStringContainsString('sid=' . $questionnaire->surveyid(), $data->navigationbar);
+ }
+
+ /**
+ * survey_results() with completed responses populates a respondent-count header.
+ */
+ public function test_survey_results_with_responses_renders_header(): void {
+ [$questionnaire, $studentid] = $this->setup_fixture();
+ $this->seed_complete_response($questionnaire, $studentid);
+
+ $questionnaire = questionnaire::from_instanceid($questionnaire->id());
+ [$reporter, $page] = $this->build_reporter($questionnaire);
+
+ $reporter->survey_results();
+
+ global $PAGE;
+ $data = $page->export_for_template($PAGE->get_renderer('mod_questionnaire'));
+ $this->assertObjectHasProperty('respondentinfo', $data);
+ $this->assertStringContainsString(
+ get_string('responses', 'questionnaire'),
+ $data->respondentinfo
+ );
+ }
+}
diff --git a/tests/local/response/response_test.php b/tests/local/response/response_test.php
new file mode 100644
index 000000000..a0dc9844d
--- /dev/null
+++ b/tests/local/response/response_test.php
@@ -0,0 +1,263 @@
+.
+
+namespace mod_questionnaire\local\response;
+
+use advanced_testcase;
+use mod_questionnaire\local\db\response_record;
+
+/**
+ * Unit tests for mod_questionnaire\local\response\response.
+ *
+ * @package mod_questionnaire
+ * @copyright 2025 Mike Churchward (mike.churchward@poetopensource.org)
+ * @author Mike Churchward
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ * @covers \mod_questionnaire\local\response\response
+ */
+final class response_test extends advanced_testcase {
+ // Helpers.
+
+ /**
+ * Insert a raw questionnaire_response row and return its id.
+ *
+ * @param int $questionnaireid
+ * @param int $userid
+ * @param string $complete 'y' or 'n'
+ * @param int|null $submitted Unix timestamp; defaults to time().
+ * @return int
+ */
+ private function insert_response(
+ int $questionnaireid,
+ int $userid,
+ string $complete = 'n',
+ ?int $submitted = null
+ ): int {
+ global $DB;
+ return (int) $DB->insert_record('questionnaire_response', (object)[
+ 'questionnaireid' => $questionnaireid,
+ 'userid' => $userid,
+ 'submitted' => $submitted ?? time(),
+ 'complete' => $complete,
+ 'grade' => 0,
+ ]);
+ }
+
+ // Tests for response::create().
+
+ /**
+ * Test that create() inserts a header row and returns a response with the correct ids.
+ */
+ public function test_create_inserts_header_row(): void {
+ global $DB;
+ $this->resetAfterTest();
+
+ $r = response::create(101, 42);
+
+ $this->assertGreaterThan(0, $r->id());
+ $this->assertSame(101, $r->questionnaireid());
+ $this->assertSame(42, $r->userid());
+ $this->assertFalse($r->is_complete());
+ $this->assertTrue($DB->record_exists('questionnaire_response', ['id' => $r->id()]));
+ }
+
+ /**
+ * Test that create() sets submitted to approximately now.
+ */
+ public function test_create_sets_submitted_to_now(): void {
+ $this->resetAfterTest();
+
+ $before = time();
+ $r = response::create(101, 42);
+ $after = time();
+
+ $this->assertGreaterThanOrEqual($before, $r->submitted_at());
+ $this->assertLessThanOrEqual($after, $r->submitted_at());
+ }
+
+ // Tests for response::latest_incomplete().
+
+ /**
+ * Test that latest_incomplete() returns null when no incomplete response exists.
+ */
+ public function test_latest_incomplete_returns_null_when_none(): void {
+ $this->resetAfterTest();
+
+ $this->assertNull(response::latest_incomplete(999, 1));
+ }
+
+ /**
+ * Test that latest_incomplete() ignores complete responses.
+ */
+ public function test_latest_incomplete_ignores_complete_responses(): void {
+ $this->resetAfterTest();
+
+ $this->insert_response(202, 7, 'y');
+
+ $this->assertNull(response::latest_incomplete(202, 7));
+ }
+
+ /**
+ * Test that latest_incomplete() returns the incomplete response id.
+ */
+ public function test_latest_incomplete_returns_incomplete_response(): void {
+ $this->resetAfterTest();
+
+ $rid = $this->insert_response(303, 8, 'n');
+
+ $r = response::latest_incomplete(303, 8);
+
+ $this->assertNotNull($r);
+ $this->assertSame($rid, $r->id());
+ $this->assertSame(303, $r->questionnaireid());
+ $this->assertSame(8, $r->userid());
+ }
+
+ /**
+ * Test that latest_incomplete() returns the most recent when multiple exist.
+ */
+ public function test_latest_incomplete_returns_most_recent(): void {
+ $this->resetAfterTest();
+
+ $this->insert_response(404, 9, 'n', time() - 100);
+ $newer = $this->insert_response(404, 9, 'n', time() - 10);
+
+ $r = response::latest_incomplete(404, 9);
+
+ $this->assertSame($newer, $r->id());
+ }
+
+ /**
+ * Test that latest_incomplete() is scoped to the given questionnaire.
+ */
+ public function test_latest_incomplete_scoped_to_questionnaire(): void {
+ $this->resetAfterTest();
+
+ $this->insert_response(501, 10, 'n');
+
+ $this->assertNull(response::latest_incomplete(502, 10));
+ }
+
+ // Accessor tests.
+
+ /**
+ * Test that is_complete() returns false for an incomplete response.
+ */
+ public function test_is_complete_false_for_incomplete(): void {
+ $this->resetAfterTest();
+
+ $rid = $this->insert_response(601, 11, 'n');
+ $r = new response(new response_record($rid));
+
+ $this->assertFalse($r->is_complete());
+ }
+
+ /**
+ * Test that is_complete() returns true for a complete response.
+ */
+ public function test_is_complete_true_for_complete(): void {
+ $this->resetAfterTest();
+
+ $rid = $this->insert_response(601, 11, 'y');
+ $r = new response(new response_record($rid));
+
+ $this->assertTrue($r->is_complete());
+ }
+
+ // Tests for touch().
+
+ /**
+ * Test that touch() updates the submitted timestamp in the database.
+ */
+ public function test_touch_updates_submitted_timestamp(): void {
+ global $DB;
+ $this->resetAfterTest();
+
+ $rid = $this->insert_response(701, 12, 'n', time() - 3600);
+ $r = new response(new response_record($rid));
+
+ $before = time();
+ $r->touch();
+ $after = time();
+
+ $stored = (int) $DB->get_field('questionnaire_response', 'submitted', ['id' => $rid]);
+ $this->assertGreaterThanOrEqual($before, $stored);
+ $this->assertLessThanOrEqual($after, $stored);
+ }
+
+ // Tests for commit().
+
+ /**
+ * Test that commit() marks the response as complete in the database.
+ */
+ public function test_commit_marks_complete(): void {
+ global $DB;
+ $this->resetAfterTest();
+
+ $rid = $this->insert_response(801, 13, 'n');
+ $r = new response(new response_record($rid));
+ $r->commit();
+
+ $row = $DB->get_record('questionnaire_response', ['id' => $rid]);
+ $this->assertSame('y', $row->complete);
+ }
+
+ /**
+ * Test that commit() stores the provided grade.
+ */
+ public function test_commit_stores_grade(): void {
+ global $DB;
+ $this->resetAfterTest();
+
+ $rid = $this->insert_response(801, 13, 'n');
+ $r = new response(new response_record($rid));
+ $r->commit(5);
+
+ $this->assertSame('5', $DB->get_field('questionnaire_response', 'grade', ['id' => $rid]));
+ }
+
+ /**
+ * Test that commit() updates submitted to approximately now.
+ */
+ public function test_commit_updates_submitted(): void {
+ global $DB;
+ $this->resetAfterTest();
+
+ $rid = $this->insert_response(801, 13, 'n', time() - 3600);
+ $r = new response(new response_record($rid));
+
+ $before = time();
+ $r->commit();
+ $after = time();
+
+ $stored = (int) $DB->get_field('questionnaire_response', 'submitted', ['id' => $rid]);
+ $this->assertGreaterThanOrEqual($before, $stored);
+ $this->assertLessThanOrEqual($after, $stored);
+ }
+
+ /**
+ * Test that after commit() is_complete() returns true.
+ */
+ public function test_is_complete_true_after_commit(): void {
+ $this->resetAfterTest();
+
+ $rid = $this->insert_response(901, 14, 'n');
+ $r = new response(new response_record($rid));
+ $r->commit();
+
+ $this->assertTrue($r->is_complete());
+ }
+}
diff --git a/tests/local/response/responsetype_test.php b/tests/local/response/responsetype_test.php
new file mode 100644
index 000000000..7df979ecd
--- /dev/null
+++ b/tests/local/response/responsetype_test.php
@@ -0,0 +1,1092 @@
+.
+
+/**
+ * Unit tests for the responsetype hierarchy under classes/local/response/.
+ *
+ * Covers the pure / state methods on the abstract base and each concrete
+ * subclass that can be exercised without a full questionnaire fixture.
+ *
+ * @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\local\response;
+
+/**
+ * Unit tests for the responsetype hierarchy.
+ *
+ * @group mod_questionnaire
+ * @covers \mod_questionnaire\local\response\responsetype
+ * @covers \mod_questionnaire\local\response\boolean
+ * @covers \mod_questionnaire\local\response\text
+ * @covers \mod_questionnaire\local\response\date
+ * @covers \mod_questionnaire\local\response\file
+ * @covers \mod_questionnaire\local\response\single
+ * @covers \mod_questionnaire\local\response\multiple
+ * @covers \mod_questionnaire\local\response\rank
+ * @covers \mod_questionnaire\local\response\slider
+ * @covers \mod_questionnaire\local\response\numericaltext
+ */
+final class responsetype_test extends \advanced_testcase {
+ /**
+ * Ensure question.php is loaded so its QUES* constants (QUESYESNO, QUESCHECK, …) are
+ * available at test-argument evaluation time, even when a test runs in isolation.
+ */
+ public static function setUpBeforeClass(): void {
+ parent::setUpBeforeClass();
+ class_exists(\mod_questionnaire\local\question\question::class);
+ }
+
+ /**
+ * Build a minimal question stub with an id, suitable for instantiating a responsetype.
+ *
+ * @param int $id
+ * @param array $choices
+ * @return \mod_questionnaire\local\question\question
+ */
+ private function question_stub(int $id, array $choices = []): \mod_questionnaire\local\question\question {
+ $stub = new class extends \mod_questionnaire\local\question\question {
+ /** @var int Captured question id for the stub. */
+ public $stubid = 0;
+
+ /**
+ * Bypass the real constructor — the stub does not need DB-backed state.
+ */
+ public function __construct() {
+ }
+
+ /**
+ * Return the stub's captured id.
+ *
+ * @return int
+ */
+ public function id(): int {
+ return $this->stubid;
+ }
+
+ /**
+ * Help name placeholder.
+ *
+ * @return string
+ */
+ public function helpname(): string {
+ return 'stub';
+ }
+
+ /**
+ * Response table placeholder — not exercised by these tests.
+ *
+ * @return string
+ */
+ public function response_table() {
+ return '';
+ }
+
+ /**
+ * Responseclass placeholder — not exercised by these tests.
+ *
+ * @return string
+ */
+ protected function responseclass() {
+ return '';
+ }
+
+ /**
+ * Question display placeholder satisfying the abstract contract.
+ *
+ * @param mixed $response
+ * @param mixed $descendantsdata
+ * @param bool $blankquestionnaire
+ * @return string
+ */
+ protected function question_survey_display($response, $descendantsdata, $blankquestionnaire = false) {
+ return '';
+ }
+
+ /**
+ * Response display placeholder satisfying the abstract contract.
+ *
+ * @param mixed $response
+ * @return string
+ */
+ protected function response_survey_display($response) {
+ return '';
+ }
+ };
+ $stub->stubid = $id;
+ $stub->choices = $choices;
+ return $stub;
+ }
+
+ /**
+ * all_response_tables() lists every per-type answer table the plugin owns.
+ */
+ public function test_all_response_tables(): void {
+ $tables = responsetype::all_response_tables();
+ $this->assertContains('questionnaire_response_bool', $tables);
+ $this->assertContains('questionnaire_response_date', $tables);
+ $this->assertContains('questionnaire_response_text', $tables);
+ $this->assertContains('questionnaire_response_rank', $tables);
+ $this->assertContains('questionnaire_response_other', $tables);
+ $this->assertContains('questionnaire_resp_single', $tables);
+ $this->assertContains('questionnaire_resp_multiple', $tables);
+ }
+
+ /**
+ * Each subclass's static response_table() returns the expected table name.
+ */
+ public function test_response_table_per_subclass(): void {
+ $this->assertSame('questionnaire_response_bool', boolean::response_table());
+ $this->assertSame('questionnaire_response_text', text::response_table());
+ $this->assertSame('questionnaire_response_date', date::response_table());
+ $this->assertSame('questionnaire_response_file', file::response_table());
+ $this->assertSame('questionnaire_resp_single', single::response_table());
+ $this->assertSame('questionnaire_resp_multiple', multiple::response_table());
+ $this->assertSame('questionnaire_response_rank', rank::response_table());
+ // Slider and numericaltext inherit text's table.
+ $this->assertSame('questionnaire_response_text', slider::response_table());
+ $this->assertSame('questionnaire_response_text', numericaltext::response_table());
+ }
+
+ /**
+ * The base transform_choiceid() is an identity pass-through.
+ */
+ public function test_transform_choiceid_default_is_identity(): void {
+ $rt = new text($this->question_stub(1));
+ $this->assertSame(42, $rt->transform_choiceid(42));
+ $this->assertSame('foo', $rt->transform_choiceid('foo'));
+ }
+
+ /**
+ * boolean::transform_choiceid() maps 0 -> 'y' and any other value -> 'n'.
+ */
+ public function test_transform_choiceid_boolean_override(): void {
+ $rt = new boolean($this->question_stub(1));
+ $this->assertSame('y', $rt->transform_choiceid(0));
+ $this->assertSame('n', $rt->transform_choiceid(1));
+ $this->assertSame('n', $rt->transform_choiceid(99));
+ }
+
+ /**
+ * results_template() returns false on the base; subclasses that supply one return a string.
+ */
+ public function test_results_template(): void {
+ $base = new text($this->question_stub(1));
+ // The base default is false, but text overrides — verify each concrete returns expected.
+ $this->assertNotEmpty($base->results_template());
+
+ $bool = new boolean($this->question_stub(1));
+ $this->assertNotEmpty($bool->results_template());
+ $this->assertNotEmpty($bool->results_template(true));
+
+ $rank = new rank($this->question_stub(1));
+ $this->assertNotEmpty($rank->results_template());
+
+ $single = new single($this->question_stub(1));
+ $this->assertNotEmpty($single->results_template());
+ }
+
+ /**
+ * set_display_context() sets the protected state used by render-time methods.
+ */
+ public function test_set_display_context_state(): void {
+ $rt = new boolean($this->question_stub(1));
+ // Access via reflection — the fields are protected.
+ $rt->set_display_context(true, 'anonymous', 42);
+ $reflection = new \ReflectionObject($rt);
+
+ $canview = $reflection->getProperty('canviewsingleresponse');
+ $canview->setAccessible(true);
+ $this->assertTrue($canview->getValue($rt));
+
+ $type = $reflection->getProperty('displayrespondenttype');
+ $type->setAccessible(true);
+ $this->assertSame('anonymous', $type->getValue($rt));
+
+ $sid = $reflection->getProperty('displaysurveysid');
+ $sid->setAccessible(true);
+ $this->assertSame(42, $sid->getValue($rt));
+ }
+
+ /**
+ * boolean::answers_from_webform() turns 'y' / 'n' form input into one answer per question.
+ */
+ public function test_answers_from_webform_boolean(): void {
+ $question = $this->question_stub(7);
+ $data = (object)['rid' => 99, 'q7' => 'y'];
+ $answers = boolean::answers_from_webform($data, $question);
+ $this->assertCount(1, $answers);
+ $a = reset($answers);
+ $this->assertEquals(99, $a->responseid);
+ $this->assertEquals(7, $a->questionid);
+ $this->assertSame('y', $a->choiceid);
+ }
+
+ /**
+ * boolean::answers_from_webform() returns an empty array when the user did not answer.
+ */
+ public function test_answers_from_webform_boolean_empty(): void {
+ $question = $this->question_stub(7);
+ $data = (object)['rid' => 99]; // No q7 property.
+ $this->assertSame([], boolean::answers_from_webform($data, $question));
+ }
+
+ /**
+ * text::answers_from_webform() captures the free-form value when present.
+ */
+ public function test_answers_from_webform_text(): void {
+ $question = $this->question_stub(11);
+ $data = (object)['rid' => 50, 'q11' => 'a written answer'];
+ $answers = text::answers_from_webform($data, $question);
+ $this->assertCount(1, $answers);
+ $a = reset($answers);
+ $this->assertEquals(50, $a->responseid);
+ $this->assertEquals(11, $a->questionid);
+ $this->assertSame('a written answer', $a->value);
+ }
+
+ /**
+ * text::answers_from_webform() returns nothing for an empty string.
+ */
+ public function test_answers_from_webform_text_empty(): void {
+ $question = $this->question_stub(11);
+ $this->assertSame([], text::answers_from_webform((object)['rid' => 50, 'q11' => ''], $question));
+ }
+
+ /**
+ * Build a real course + questionnaire with one question of the given type, plus an empty
+ * parent response row. Returns [questionnaire, question, parent response id].
+ *
+ * @param int $qtype Question type constant (QUESYESNO, QUESTEXT, etc).
+ * @param array|null $choicedata Optional choice rows for choice-based question types.
+ * @return array
+ */
+ private function build_data_fixture(int $qtype, ?array $choicedata = null): array {
+ global $DB, $USER;
+
+ $course = $this->getDataGenerator()->create_course();
+ $generator = $this->getDataGenerator()->get_plugin_generator('mod_questionnaire');
+ $questionnaire = $generator->create_test_questionnaire(
+ $course,
+ $qtype,
+ ['content' => 'data fixture'],
+ $choicedata
+ );
+ $questions = $questionnaire->questions();
+ $question = reset($questions);
+
+ // Use $USER->id so text::get_results' user join finds a row in its non-anonymous branch.
+ $rid = $DB->insert_record('questionnaire_response', (object)[
+ 'questionnaireid' => $questionnaire->id(),
+ 'userid' => $USER->id,
+ 'submitted' => time(),
+ 'complete' => 'n',
+ 'grade' => 0,
+ ]);
+ return [$questionnaire, $question, (int)$rid];
+ }
+
+ /**
+ * boolean::insert_response() writes a 'y' answer to questionnaire_response_bool.
+ */
+ public function test_boolean_insert_response_writes_record(): void {
+ global $DB;
+ $this->resetAfterTest();
+ $this->setAdminUser();
+
+ [$questionnaire, $question, $rid] = $this->build_data_fixture(QUESYESNO);
+
+ $rt = new boolean($question);
+ $data = (object)['rid' => $rid, 'a' => $questionnaire->id(), 'q' . $question->id() => 'y'];
+ $insertedid = $rt->insert_response($data);
+
+ $this->assertNotFalse($insertedid);
+ $row = $DB->get_record('questionnaire_response_bool', ['id' => $insertedid]);
+ $this->assertEquals($rid, $row->responseid);
+ $this->assertEquals($question->id(), $row->questionid);
+ $this->assertSame('y', $row->choiceid);
+ }
+
+ /**
+ * boolean::get_results() returns one count per choice id.
+ */
+ public function test_boolean_get_results_counts_choices(): void {
+ global $DB, $USER;
+ $this->resetAfterTest();
+ $this->setAdminUser();
+
+ [$questionnaire, $question, $rid] = $this->build_data_fixture(QUESYESNO);
+ $rt = new boolean($question);
+
+ // Three responses: two 'y', one 'n', each in its own parent response row.
+ $rt->insert_response((object)['rid' => $rid, 'a' => $questionnaire->id(), 'q' . $question->id() => 'y']);
+ $rid2 = $DB->insert_record('questionnaire_response', (object)[
+ 'questionnaireid' => $questionnaire->id(), 'userid' => $USER->id,
+ 'submitted' => time(), 'complete' => 'n', 'grade' => 0,
+ ]);
+ $rt->insert_response((object)['rid' => $rid2, 'a' => $questionnaire->id(), 'q' . $question->id() => 'y']);
+ $rid3 = $DB->insert_record('questionnaire_response', (object)[
+ 'questionnaireid' => $questionnaire->id(), 'userid' => $USER->id,
+ 'submitted' => time(), 'complete' => 'n', 'grade' => 0,
+ ]);
+ $rt->insert_response((object)['rid' => $rid3, 'a' => $questionnaire->id(), 'q' . $question->id() => 'n']);
+
+ $results = $rt->get_results([$rid, $rid2, $rid3]);
+ $bychoice = [];
+ foreach ($results as $row) {
+ $bychoice[$row->choiceid] = (int)$row->num;
+ }
+ $this->assertSame(2, $bychoice['y'] ?? 0);
+ $this->assertSame(1, $bychoice['n'] ?? 0);
+ }
+
+ /**
+ * boolean::display_results() returns a templatable object derived from the counts.
+ */
+ public function test_boolean_display_results_returns_tags(): void {
+ $this->resetAfterTest();
+ $this->setAdminUser();
+
+ [$questionnaire, $question, $rid] = $this->build_data_fixture(QUESYESNO);
+ $rt = new boolean($question);
+ $rt->insert_response((object)['rid' => $rid, 'a' => $questionnaire->id(), 'q' . $question->id() => 'y']);
+
+ $tags = $rt->display_results([$rid], '', false);
+ $this->assertIsObject($tags);
+ // Get_results_tags fills counts via $pagetags->counts; verify the structure renders something.
+ $this->assertNotEmpty(get_object_vars($tags));
+ }
+
+ /**
+ * text::insert_response() writes the clean text value to questionnaire_response_text.
+ */
+ public function test_text_insert_response_writes_value(): void {
+ global $DB;
+ $this->resetAfterTest();
+ $this->setAdminUser();
+
+ [$questionnaire, $question, $rid] = $this->build_data_fixture(QUESTEXT);
+
+ $rt = new text($question);
+ $data = (object)[
+ 'rid' => $rid,
+ 'a' => $questionnaire->id(),
+ 'q' . $question->id() => 'free-form answer',
+ ];
+ $insertedid = $rt->insert_response($data);
+
+ $this->assertNotFalse($insertedid);
+ $row = $DB->get_record('questionnaire_response_text', ['id' => $insertedid]);
+ $this->assertEquals($rid, $row->responseid);
+ $this->assertEquals($question->id(), $row->questionid);
+ $this->assertSame('free-form answer', $row->response);
+ }
+
+ /**
+ * text::get_results() returns the inserted text answers joined with the parent response.
+ */
+ public function test_text_get_results_returns_value(): void {
+ $this->resetAfterTest();
+ $this->setAdminUser();
+
+ [$questionnaire, $question, $rid] = $this->build_data_fixture(QUESTEXT);
+ $rt = new text($question);
+ $rt->insert_response((object)[
+ 'rid' => $rid,
+ 'a' => $questionnaire->id(),
+ 'q' . $question->id() => 'hello world',
+ ]);
+
+ $results = $rt->get_results([$rid]);
+ $this->assertCount(1, $results);
+ $row = reset($results);
+ $this->assertSame('hello world', $row->response);
+ }
+
+ /**
+ * date::insert_response() writes a YYYY-MM-DD value to questionnaire_response_date.
+ */
+ public function test_date_insert_response_writes_value(): void {
+ global $DB;
+ $this->resetAfterTest();
+ $this->setAdminUser();
+
+ [$questionnaire, $question, $rid] = $this->build_data_fixture(QUESDATE);
+
+ $rt = new date($question);
+ $insertedid = $rt->insert_response((object)[
+ 'rid' => $rid,
+ 'a' => $questionnaire->id(),
+ 'q' . $question->id() => '2026-06-14',
+ ]);
+
+ $this->assertNotFalse($insertedid);
+ $row = $DB->get_record('questionnaire_response_date', ['id' => $insertedid]);
+ $this->assertEquals($rid, $row->responseid);
+ $this->assertEquals($question->id(), $row->questionid);
+ $this->assertSame('2026-06-14', $row->response);
+ }
+
+ /**
+ * date::insert_response() rejects badly-formatted input (returns false, writes nothing).
+ */
+ public function test_date_insert_response_rejects_bad_format(): void {
+ global $DB;
+ $this->resetAfterTest();
+ $this->setAdminUser();
+
+ [$questionnaire, $question, $rid] = $this->build_data_fixture(QUESDATE);
+
+ $rt = new date($question);
+ $result = $rt->insert_response((object)[
+ 'rid' => $rid,
+ 'a' => $questionnaire->id(),
+ 'q' . $question->id() => 'not-a-date',
+ ]);
+
+ $this->assertFalse($result);
+ $this->assertSame(0, $DB->count_records('questionnaire_response_date', ['responseid' => $rid]));
+ }
+
+ /**
+ * date::get_results() returns the stored date rows for the given response ids.
+ */
+ public function test_date_get_results_returns_dates(): void {
+ $this->resetAfterTest();
+ $this->setAdminUser();
+
+ [$questionnaire, $question, $rid] = $this->build_data_fixture(QUESDATE);
+ $rt = new date($question);
+ $rt->insert_response((object)[
+ 'rid' => $rid,
+ 'a' => $questionnaire->id(),
+ 'q' . $question->id() => '2026-06-14',
+ ]);
+
+ $results = $rt->get_results([$rid]);
+ $this->assertCount(1, $results);
+ $row = reset($results);
+ $this->assertSame('2026-06-14', $row->response);
+ }
+
+ /**
+ * single::insert_response() writes one questionnaire_resp_single row for the chosen choice id.
+ */
+ public function test_single_insert_response_writes_choice(): void {
+ global $DB;
+ $this->resetAfterTest();
+ $this->setAdminUser();
+
+ $choices = [
+ (object)['content' => 'One', 'value' => 1],
+ (object)['content' => 'Two', 'value' => 2],
+ (object)['content' => 'Three', 'value' => 3],
+ ];
+ [$questionnaire, $question, $rid] = $this->build_data_fixture(QUESRADIO, $choices);
+
+ // Pick the "Two" choice id from the question's loaded choices.
+ $twoid = 0;
+ foreach ($question->choices as $cid => $choice) {
+ if ($choice->content === 'Two') {
+ $twoid = $cid;
+ break;
+ }
+ }
+ $this->assertNotEmpty($twoid);
+
+ $rt = new single($question);
+ $insertedid = $rt->insert_response((object)[
+ 'rid' => $rid,
+ 'a' => $questionnaire->id(),
+ 'q' . $question->id() => $twoid,
+ ]);
+
+ $this->assertNotFalse($insertedid);
+ $row = $DB->get_record('questionnaire_resp_single', ['id' => $insertedid]);
+ $this->assertEquals($rid, $row->responseid);
+ $this->assertEquals($question->id(), $row->questionid);
+ $this->assertEquals($twoid, $row->choiceid);
+ }
+
+ /**
+ * single::get_results() joins the response and choice rows so each result carries the choice content.
+ */
+ public function test_single_get_results_returns_choice_content(): void {
+ $this->resetAfterTest();
+ $this->setAdminUser();
+
+ $choices = [
+ (object)['content' => 'One', 'value' => 1],
+ (object)['content' => 'Two', 'value' => 2],
+ ];
+ [$questionnaire, $question, $rid] = $this->build_data_fixture(QUESRADIO, $choices);
+
+ $twoid = 0;
+ foreach ($question->choices as $cid => $choice) {
+ if ($choice->content === 'Two') {
+ $twoid = $cid;
+ break;
+ }
+ }
+
+ $rt = new single($question);
+ $rt->insert_response((object)[
+ 'rid' => $rid,
+ 'a' => $questionnaire->id(),
+ 'q' . $question->id() => $twoid,
+ ]);
+
+ $results = $rt->get_results([$rid]);
+ $contents = array_map(fn($r) => $r->content ?? null, $results);
+ $this->assertContains('Two', $contents);
+ }
+
+ /**
+ * rank::insert_response() writes one questionnaire_response_rank row per ranked choice.
+ */
+ public function test_rank_insert_response_writes_rows_per_choice(): void {
+ global $DB;
+ $this->resetAfterTest();
+ $this->setAdminUser();
+
+ $choices = [
+ (object)['content' => 'Speed', 'value' => null],
+ (object)['content' => 'Quality', 'value' => null],
+ (object)['content' => 'Price', 'value' => null],
+ ];
+ [$questionnaire, $question, $rid] = $this->build_data_fixture(QUESRATE, $choices);
+
+ // Resolve choice ids by content so the form data uses real ids.
+ $choiceidsbycontent = [];
+ foreach ($question->choices as $cid => $choice) {
+ $choiceidsbycontent[$choice->content] = $cid;
+ }
+
+ $rt = new rank($question);
+ $data = (object)[
+ 'rid' => $rid,
+ 'a' => $questionnaire->id(),
+ 'q' . $question->id() . '_' . $choiceidsbycontent['Speed'] => '1',
+ 'q' . $question->id() . '_' . $choiceidsbycontent['Quality'] => '2',
+ 'q' . $question->id() . '_' . $choiceidsbycontent['Price'] => '3',
+ ];
+ $rt->insert_response($data);
+
+ $rows = $DB->get_records('questionnaire_response_rank', [
+ 'responseid' => $rid,
+ 'questionid' => $question->id(),
+ ]);
+ $this->assertCount(3, $rows);
+ $bychoice = [];
+ foreach ($rows as $row) {
+ $bychoice[(int)$row->choiceid] = (int)$row->rankvalue;
+ }
+ $this->assertSame(1, $bychoice[$choiceidsbycontent['Speed']]);
+ $this->assertSame(2, $bychoice[$choiceidsbycontent['Quality']]);
+ $this->assertSame(3, $bychoice[$choiceidsbycontent['Price']]);
+ }
+
+ /**
+ * rank::insert_response() with "Not applicable" stores -1 (excluded from get_results averages).
+ */
+ public function test_rank_insert_response_marks_notapplicable_as_minus_one(): void {
+ global $DB;
+ $this->resetAfterTest();
+ $this->setAdminUser();
+
+ $choices = [
+ (object)['content' => 'Speed', 'value' => null],
+ (object)['content' => 'Quality', 'value' => null],
+ ];
+ [$questionnaire, $question, $rid] = $this->build_data_fixture(QUESRATE, $choices);
+
+ $choiceidsbycontent = [];
+ foreach ($question->choices as $cid => $choice) {
+ $choiceidsbycontent[$choice->content] = $cid;
+ }
+
+ $rt = new rank($question);
+ $data = (object)[
+ 'rid' => $rid,
+ 'a' => $questionnaire->id(),
+ 'q' . $question->id() . '_' . $choiceidsbycontent['Speed'] => '2',
+ 'q' . $question->id() . '_' . $choiceidsbycontent['Quality'] => get_string('notapplicable', 'questionnaire'),
+ ];
+ $rt->insert_response($data);
+
+ $qualityrow = $DB->get_record('questionnaire_response_rank', [
+ 'responseid' => $rid,
+ 'choiceid' => $choiceidsbycontent['Quality'],
+ ]);
+ $this->assertEquals(-1, $qualityrow->rankvalue);
+ }
+
+ /**
+ * rank::get_results() returns one row per choice with its average rank value across responses.
+ */
+ public function test_rank_get_results_returns_averages_by_choice(): void {
+ global $DB, $USER;
+ $this->resetAfterTest();
+ $this->setAdminUser();
+
+ $choices = [
+ (object)['content' => 'Speed', 'value' => null],
+ (object)['content' => 'Quality', 'value' => null],
+ ];
+ [$questionnaire, $question, $rid] = $this->build_data_fixture(QUESRATE, $choices);
+
+ $choiceidsbycontent = [];
+ foreach ($question->choices as $cid => $choice) {
+ $choiceidsbycontent[$choice->content] = $cid;
+ }
+ $speedid = $choiceidsbycontent['Speed'];
+ $qualityid = $choiceidsbycontent['Quality'];
+
+ $rt = new rank($question);
+
+ // Two responses: speed=1/quality=2 and speed=3/quality=4 → avg speed=2, avg quality=3.
+ $rt->insert_response((object)[
+ 'rid' => $rid,
+ 'a' => $questionnaire->id(),
+ 'q' . $question->id() . '_' . $speedid => '1',
+ 'q' . $question->id() . '_' . $qualityid => '2',
+ ]);
+ $rid2 = $DB->insert_record('questionnaire_response', (object)[
+ 'questionnaireid' => $questionnaire->id(), 'userid' => $USER->id,
+ 'submitted' => time(), 'complete' => 'n', 'grade' => 0,
+ ]);
+ $rt->insert_response((object)[
+ 'rid' => $rid2,
+ 'a' => $questionnaire->id(),
+ 'q' . $question->id() . '_' . $speedid => '3',
+ 'q' . $question->id() . '_' . $qualityid => '4',
+ ]);
+
+ $results = $rt->get_results([$rid, $rid2]);
+ // Results are reindexed by content.
+ $this->assertArrayHasKey('Speed', $results);
+ $this->assertArrayHasKey('Quality', $results);
+ $this->assertEqualsWithDelta(2.0, (float)$results['Speed']->average, 0.0001);
+ $this->assertEqualsWithDelta(3.0, (float)$results['Quality']->average, 0.0001);
+ $this->assertSame(2, (int)$results['Speed']->num);
+ $this->assertSame(2, (int)$results['Quality']->num);
+ }
+
+ /**
+ * rank::display_results() returns a templatable stdClass once results exist.
+ */
+ public function test_rank_display_results_returns_tags(): void {
+ $this->resetAfterTest();
+ $this->setAdminUser();
+
+ $choices = [
+ (object)['content' => 'Speed', 'value' => null],
+ (object)['content' => 'Quality', 'value' => null],
+ ];
+ [$questionnaire, $question, $rid] = $this->build_data_fixture(QUESRATE, $choices);
+
+ $choiceidsbycontent = [];
+ foreach ($question->choices as $cid => $choice) {
+ $choiceidsbycontent[$choice->content] = $cid;
+ }
+
+ $rt = new rank($question);
+ $rt->insert_response((object)[
+ 'rid' => $rid,
+ 'a' => $questionnaire->id(),
+ 'q' . $question->id() . '_' . $choiceidsbycontent['Speed'] => '1',
+ 'q' . $question->id() . '_' . $choiceidsbycontent['Quality'] => '2',
+ ]);
+
+ $pagetags = $rt->display_results([$rid]);
+ $this->assertIsObject($pagetags);
+ }
+
+ /**
+ * multiple::insert_response() writes one questionnaire_resp_multiple row per checked choice.
+ */
+ public function test_multiple_insert_response_writes_rows_per_choice(): void {
+ global $DB;
+ $this->resetAfterTest();
+ $this->setAdminUser();
+
+ $choices = [
+ (object)['content' => 'Red', 'value' => null],
+ (object)['content' => 'Green', 'value' => null],
+ (object)['content' => 'Blue', 'value' => null],
+ ];
+ [$questionnaire, $question, $rid] = $this->build_data_fixture(QUESCHECK, $choices);
+
+ $choiceidsbycontent = [];
+ foreach ($question->choices as $cid => $choice) {
+ $choiceidsbycontent[$choice->content] = $cid;
+ }
+ $redid = $choiceidsbycontent['Red'];
+ $blueid = $choiceidsbycontent['Blue'];
+
+ $rt = new multiple($question);
+ // Webform format: q{qid} is an array keyed by selected choice ids.
+ $data = (object)[
+ 'rid' => $rid,
+ 'a' => $questionnaire->id(),
+ 'q' . $question->id() => [$redid => $redid, $blueid => $blueid],
+ ];
+ $rt->insert_response($data);
+
+ // Exactly two rows landed in the multi table; no rows for Green.
+ $rows = $DB->get_records('questionnaire_resp_multiple', [
+ 'responseid' => $rid,
+ 'questionid' => $question->id(),
+ ]);
+ $this->assertCount(2, $rows);
+ $chosen = array_map(fn($r) => (int)$r->choiceid, $rows);
+ sort($chosen);
+ $expected = [$redid, $blueid];
+ sort($expected);
+ $this->assertSame($expected, $chosen);
+
+ // Sibling single table is empty — confirms make_primary_record routed to the right persistent.
+ $this->assertSame(0, $DB->count_records('questionnaire_resp_single', [
+ 'responseid' => $rid,
+ 'questionid' => $question->id(),
+ ]));
+ }
+
+ /**
+ * multiple::get_results() joins the resp_multiple table so each result row carries the choice content.
+ */
+ public function test_multiple_get_results_returns_choice_content(): void {
+ $this->resetAfterTest();
+ $this->setAdminUser();
+
+ $choices = [
+ (object)['content' => 'Red', 'value' => null],
+ (object)['content' => 'Green', 'value' => null],
+ (object)['content' => 'Blue', 'value' => null],
+ ];
+ [$questionnaire, $question, $rid] = $this->build_data_fixture(QUESCHECK, $choices);
+
+ $choiceidsbycontent = [];
+ foreach ($question->choices as $cid => $choice) {
+ $choiceidsbycontent[$choice->content] = $cid;
+ }
+
+ $rt = new multiple($question);
+ $rt->insert_response((object)[
+ 'rid' => $rid,
+ 'a' => $questionnaire->id(),
+ 'q' . $question->id() => [
+ $choiceidsbycontent['Red'] => $choiceidsbycontent['Red'],
+ $choiceidsbycontent['Green'] => $choiceidsbycontent['Green'],
+ ],
+ ]);
+
+ $results = $rt->get_results([$rid]);
+ $contents = array_map(fn($r) => $r->content ?? null, $results);
+ $this->assertContains('Red', $contents);
+ $this->assertContains('Green', $contents);
+ $this->assertNotContains('Blue', $contents);
+ }
+
+ /**
+ * multiple::response_select() returns a per-question array keyed by the chosen choice ids.
+ */
+ public function test_multiple_response_select_returns_structured_array(): void {
+ $this->resetAfterTest();
+ $this->setAdminUser();
+
+ $choices = [
+ (object)['content' => 'Red', 'value' => null],
+ (object)['content' => 'Green', 'value' => null],
+ ];
+ [$questionnaire, $question, $rid] = $this->build_data_fixture(QUESCHECK, $choices);
+
+ $choiceidsbycontent = [];
+ foreach ($question->choices as $cid => $choice) {
+ $choiceidsbycontent[$choice->content] = $cid;
+ }
+ $redid = $choiceidsbycontent['Red'];
+ $greenid = $choiceidsbycontent['Green'];
+
+ $rt = new multiple($question);
+ $rt->insert_response((object)[
+ 'rid' => $rid,
+ 'a' => $questionnaire->id(),
+ 'q' . $question->id() => [$redid => $redid, $greenid => $greenid],
+ ]);
+
+ $values = multiple::response_select($rid);
+ $this->assertArrayHasKey($question->id(), $values);
+ $this->assertArrayHasKey('responses', $values[$question->id()]);
+ $responses = $values[$question->id()]['responses'];
+ $this->assertArrayHasKey($redid, $responses);
+ $this->assertArrayHasKey($greenid, $responses);
+ }
+
+ /**
+ * numericaltext::answers_from_webform() returns the parsed value for numeric input.
+ */
+ public function test_numericaltext_answers_from_webform_returns_numeric_value(): void {
+ $this->resetAfterTest();
+ $this->setAdminUser();
+
+ [, $question, $rid] = $this->build_data_fixture(QUESNUMERIC);
+
+ $data = (object)['rid' => $rid, 'q' . $question->id() => '12.5'];
+ $answers = numericaltext::answers_from_webform($data, $question);
+
+ $this->assertCount(1, $answers);
+ $answer = reset($answers);
+ $this->assertSame('12.5', $answer->value);
+ }
+
+ /**
+ * numericaltext::answers_from_webform() returns an empty array for non-numeric input.
+ *
+ * The is_numeric() gate also rejects "12,5" — comma-decimal input never reaches the
+ * comma-to-dot cleanup path, so the cleanup only ever runs on already-numeric strings.
+ */
+ public function test_numericaltext_answers_from_webform_rejects_non_numeric(): void {
+ $this->resetAfterTest();
+ $this->setAdminUser();
+
+ [, $question, $rid] = $this->build_data_fixture(QUESNUMERIC);
+
+ foreach (['hello', '12,5abc', ''] as $bad) {
+ $data = (object)['rid' => $rid, 'q' . $question->id() => $bad];
+ $this->assertSame([], numericaltext::answers_from_webform($data, $question), "input: $bad");
+ }
+ }
+
+ /**
+ * numericaltext::insert_response() writes the numeric value into questionnaire_response_text.
+ */
+ public function test_numericaltext_insert_response_writes_value(): void {
+ global $DB;
+ $this->resetAfterTest();
+ $this->setAdminUser();
+
+ [$questionnaire, $question, $rid] = $this->build_data_fixture(QUESNUMERIC);
+
+ $rt = new numericaltext($question);
+ $insertedid = $rt->insert_response((object)[
+ 'rid' => $rid,
+ 'a' => $questionnaire->id(),
+ 'q' . $question->id() => '42.7',
+ ]);
+
+ $this->assertNotFalse($insertedid);
+ $row = $DB->get_record('questionnaire_response_text', ['id' => $insertedid]);
+ $this->assertSame('42.7', $row->response);
+ }
+
+ /**
+ * numericaltext::display_results() groups identical numeric responses with counts on the page tags.
+ */
+ public function test_numericaltext_display_results_groups_identical_counts(): void {
+ global $DB, $USER;
+ $this->resetAfterTest();
+ $this->setAdminUser();
+
+ [$questionnaire, $question, $rid] = $this->build_data_fixture(QUESNUMERIC);
+ $rt = new numericaltext($question);
+
+ // Three responses: 5, 5, 7 → grouped as 5:2, 7:1.
+ $rt->insert_response((object)[
+ 'rid' => $rid, 'a' => $questionnaire->id(), 'q' . $question->id() => '5',
+ ]);
+ $rid2 = $DB->insert_record('questionnaire_response', (object)[
+ 'questionnaireid' => $questionnaire->id(), 'userid' => $USER->id,
+ 'submitted' => time(), 'complete' => 'n', 'grade' => 0,
+ ]);
+ $rt->insert_response((object)[
+ 'rid' => $rid2, 'a' => $questionnaire->id(), 'q' . $question->id() => '5',
+ ]);
+ $rid3 = $DB->insert_record('questionnaire_response', (object)[
+ 'questionnaireid' => $questionnaire->id(), 'userid' => $USER->id,
+ 'submitted' => time(), 'complete' => 'n', 'grade' => 0,
+ ]);
+ $rt->insert_response((object)[
+ 'rid' => $rid3, 'a' => $questionnaire->id(), 'q' . $question->id() => '7',
+ ]);
+
+ $pagetags = $rt->display_results([$rid, $rid2, $rid3]);
+ $this->assertIsObject($pagetags);
+ }
+
+ /**
+ * slider::response_answers_by_question() returns the answer keyed by question id, and decodes
+ * extradata json from questionnaire_question when present.
+ */
+ public function test_slider_response_answers_by_question_returns_answer_with_extradata(): void {
+ global $DB;
+ $this->resetAfterTest();
+ $this->setAdminUser();
+
+ [$questionnaire, $question, $rid] = $this->build_data_fixture(QUESSLIDER);
+
+ // The generator does not seed extradata; set a realistic blob directly.
+ $DB->set_field('questionnaire_question', 'extradata', json_encode([
+ 'minrange' => 0,
+ 'maxrange' => 10,
+ 'startingvalue' => 5,
+ ]), ['id' => $question->id()]);
+
+ $rt = new slider($question);
+ $rt->insert_response((object)[
+ 'rid' => $rid,
+ 'a' => $questionnaire->id(),
+ 'q' . $question->id() => '7',
+ ]);
+
+ $answers = slider::response_answers_by_question($rid);
+
+ $this->assertArrayHasKey($question->id(), $answers);
+ $this->assertSame('7', $answers[$question->id()][0]->value);
+ $this->assertObjectHasProperty('maxrange', $answers[$question->id()]['extradata']);
+ $this->assertSame(10, $answers[$question->id()]['extradata']->maxrange);
+ }
+
+ /**
+ * slider::response_answers_by_question() omits the extradata entry when the question has none.
+ */
+ public function test_slider_response_answers_by_question_no_extradata(): void {
+ $this->resetAfterTest();
+ $this->setAdminUser();
+
+ [$questionnaire, $question, $rid] = $this->build_data_fixture(QUESSLIDER);
+
+ $rt = new slider($question);
+ $rt->insert_response((object)[
+ 'rid' => $rid,
+ 'a' => $questionnaire->id(),
+ 'q' . $question->id() => '3',
+ ]);
+
+ $answers = slider::response_answers_by_question($rid);
+
+ $this->assertArrayHasKey($question->id(), $answers);
+ $this->assertArrayNotHasKey('extradata', $answers[$question->id()]);
+ }
+
+ /**
+ * slider::get_feedback_scores() returns one row per response with the numeric score.
+ */
+ public function test_slider_get_feedback_scores_returns_score_per_response(): void {
+ global $DB, $USER;
+ $this->resetAfterTest();
+ $this->setAdminUser();
+
+ [$questionnaire, $question, $rid] = $this->build_data_fixture(QUESSLIDER);
+ $rt = new slider($question);
+
+ $rt->insert_response((object)[
+ 'rid' => $rid, 'a' => $questionnaire->id(), 'q' . $question->id() => '4',
+ ]);
+ $rid2 = $DB->insert_record('questionnaire_response', (object)[
+ 'questionnaireid' => $questionnaire->id(), 'userid' => $USER->id,
+ 'submitted' => time(), 'complete' => 'n', 'grade' => 0,
+ ]);
+ $rt->insert_response((object)[
+ 'rid' => $rid2, 'a' => $questionnaire->id(), 'q' . $question->id() => '8',
+ ]);
+
+ $scores = $rt->get_feedback_scores([$rid, $rid2]);
+ $this->assertCount(2, $scores);
+ $byrid = [];
+ foreach ($scores as $row) {
+ $byrid[(int)$row->rid] = $row->score;
+ }
+ $this->assertSame('4', $byrid[$rid]);
+ $this->assertSame('8', $byrid[$rid2]);
+ }
+
+ /**
+ * file::response_answers_by_question() returns the file answer keyed by question id.
+ */
+ public function test_file_response_answers_by_question_returns_rows(): void {
+ global $DB;
+ $this->resetAfterTest();
+ $this->setAdminUser();
+
+ [, $question, $rid] = $this->build_data_fixture(QUESTEXT);
+
+ // Direct insert simulates a stored file answer; we only exercise the read path here.
+ $DB->insert_record('questionnaire_response_file', (object)[
+ 'responseid' => $rid,
+ 'questionid' => $question->id(),
+ 'fileid' => 4242,
+ ]);
+
+ $answers = file::response_answers_by_question($rid);
+
+ $this->assertArrayHasKey($question->id(), $answers);
+ $this->assertCount(1, $answers[$question->id()]);
+ $this->assertSame('4242', $answers[$question->id()][0]->value);
+ }
+
+ /**
+ * file::response_select() returns one entry per question with the fileid duplicated as content + value.
+ */
+ public function test_file_response_select_returns_structured_array(): void {
+ global $DB;
+ $this->resetAfterTest();
+ $this->setAdminUser();
+
+ [, $question, $rid] = $this->build_data_fixture(QUESTEXT);
+
+ $DB->insert_record('questionnaire_response_file', (object)[
+ 'responseid' => $rid,
+ 'questionid' => $question->id(),
+ 'fileid' => 5151,
+ ]);
+
+ $values = file::response_select($rid);
+
+ $this->assertArrayHasKey($question->id(), $values);
+ // The response_select() method pops the last value and re-pushes it twice, so fileid appears at the tail.
+ $this->assertSame(5151, (int)end($values[$question->id()]));
+ }
+
+ /**
+ * file::delete_old_response() drops the questionnaire_response_file row.
+ */
+ public function test_file_delete_old_response_removes_record(): void {
+ global $DB;
+ $this->resetAfterTest();
+ $this->setAdminUser();
+
+ [, $question, $rid] = $this->build_data_fixture(QUESTEXT);
+
+ $DB->insert_record('questionnaire_response_file', (object)[
+ 'responseid' => $rid,
+ 'questionid' => $question->id(),
+ 'fileid' => 9999,
+ ]);
+ $this->assertSame(1, $DB->count_records('questionnaire_response_file', [
+ 'responseid' => $rid, 'questionid' => $question->id(),
+ ]));
+
+ file::delete_old_response((int)$question->id(), $rid);
+
+ $this->assertSame(0, $DB->count_records('questionnaire_response_file', [
+ 'responseid' => $rid, 'questionid' => $question->id(),
+ ]));
+ }
+}
diff --git a/tests/local/survey/survey_test.php b/tests/local/survey/survey_test.php
new file mode 100644
index 000000000..dc2ecd4c3
--- /dev/null
+++ b/tests/local/survey/survey_test.php
@@ -0,0 +1,893 @@
+.
+
+/**
+ * Unit tests for mod_questionnaire\local\survey\survey.
+ *
+ * Pure business-logic tests use survey_testable (no DB). Tests that require
+ * a real survey record use the DB with resetAfterTest.
+ *
+ * @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\local\survey;
+
+use mod_questionnaire\local\db\survey_record;
+
+defined('MOODLE_INTERNAL') || die();
+
+global $CFG;
+require_once($CFG->dirroot . '/mod/questionnaire/tests/local/survey/survey_testable.php');
+
+/**
+ * Unit tests for mod_questionnaire\local\survey\survey.
+ *
+ * @group mod_questionnaire
+ * @covers \mod_questionnaire\local\survey\survey
+ */
+final class survey_test extends \advanced_testcase {
+ // Helpers.
+
+ /**
+ * Build an in-memory survey_record with sensible defaults, optionally overriding fields.
+ *
+ * @param array $fields
+ * @return survey_record
+ */
+ private function make_survey_record(array $fields = []): survey_record {
+ return new survey_record(0, (object) array_merge([
+ 'id' => 1,
+ 'name' => 'Test survey',
+ 'courseid' => 1,
+ 'realm' => 'private',
+ 'status' => 0,
+ 'title' => 'My title',
+ 'email' => null,
+ 'subtitle' => null,
+ 'info' => null,
+ 'theme' => null,
+ 'thankspage' => null,
+ 'thankhead' => null,
+ 'thankbody' => null,
+ 'feedbacksections' => 0,
+ 'feedbacknotes' => null,
+ 'feedbackscores' => 0,
+ 'charttype' => null,
+ ], $fields));
+ }
+
+ /**
+ * Build a survey_testable with optional field overrides.
+ *
+ * @param array $fields
+ * @return survey_testable
+ */
+ private function make_survey(array $fields = []): survey_testable {
+ return new survey_testable($this->make_survey_record($fields));
+ }
+
+ /**
+ * Build a minimal stub question with a required() accessor for has_required() tests.
+ *
+ * @param bool $required
+ * @return object
+ */
+ private function make_required_stub(bool $required): object {
+ return new class ($required) {
+ /** @var bool */
+ private bool $req;
+ /**
+ * Constructor.
+ *
+ * @param bool $required
+ */
+ public function __construct(bool $required) {
+ $this->req = $required;
+ }
+ /**
+ * Required flag accessor.
+ *
+ * @return bool
+ */
+ public function required(): bool {
+ return $this->req;
+ }
+ };
+ }
+
+ /**
+ * Build a minimal stub question for position-helper tests.
+ *
+ * @param int $id Question id.
+ * @param int $position Question position (1-based).
+ * @param array $dependencies Array of dependency stdClass objects with dependquestionid.
+ * @return object
+ */
+ private function make_stub_question(int $id, int $position, array $dependencies = []): object {
+ return new class ($id, $position, $dependencies) {
+ /** @var int */
+ public int $qid;
+ /** @var array */
+ public array $dependencies;
+ /** @var int */
+ private int $pos;
+
+ /**
+ * Constructor.
+ *
+ * @param int $id
+ * @param int $position
+ * @param array $dependencies
+ */
+ public function __construct(int $id, int $position, array $dependencies) {
+ $this->qid = $id;
+ $this->pos = $position;
+ $this->dependencies = $dependencies;
+ }
+
+ /**
+ * Return the question id.
+ *
+ * @return int
+ */
+ public function id(): int {
+ return $this->qid;
+ }
+
+ /**
+ * Return the question position.
+ *
+ * @return int
+ */
+ public function position(): int {
+ return $this->pos;
+ }
+ };
+ }
+
+ // Tests for accessor methods (no DB).
+
+ /**
+ * Asserts title() returns the value from the survey record.
+ *
+ * @covers \mod_questionnaire\local\survey\survey::title
+ */
+ public function test_title_returns_value_from_record(): void {
+ $this->assertEquals('My title', $this->make_survey(['title' => 'My title'])->title());
+ }
+
+ /**
+ * Asserts subtitle() returns an empty string when the record value is null.
+ *
+ * @covers \mod_questionnaire\local\survey\survey::subtitle
+ */
+ public function test_subtitle_returns_empty_string_when_null(): void {
+ $this->assertSame('', $this->make_survey(['subtitle' => null])->subtitle());
+ }
+
+ /**
+ * Asserts subtitle() returns the stored value when set.
+ *
+ * @covers \mod_questionnaire\local\survey\survey::subtitle
+ */
+ public function test_subtitle_returns_value_when_set(): void {
+ $this->assertEquals('Sub', $this->make_survey(['subtitle' => 'Sub'])->subtitle());
+ }
+
+ /**
+ * Asserts info() returns an empty string when the record value is null.
+ *
+ * @covers \mod_questionnaire\local\survey\survey::info
+ */
+ public function test_info_returns_empty_string_when_null(): void {
+ $this->assertSame('', $this->make_survey(['info' => null])->info());
+ }
+
+ /**
+ * Asserts realm() returns the stored realm string.
+ *
+ * @covers \mod_questionnaire\local\survey\survey::realm
+ */
+ public function test_realm_returns_private_by_default(): void {
+ $this->assertEquals('private', $this->make_survey()->realm());
+ }
+
+ /**
+ * Asserts owning_courseid() returns the course id from the record.
+ *
+ * @covers \mod_questionnaire\local\survey\survey::owning_courseid
+ */
+ public function test_owning_courseid_returns_course_id(): void {
+ $this->assertEquals(42, $this->make_survey(['courseid' => 42])->owning_courseid());
+ }
+
+ /**
+ * Asserts thankspage() returns an empty string when the record value is null.
+ *
+ * @covers \mod_questionnaire\local\survey\survey::thankspage
+ */
+ public function test_thankspage_returns_empty_when_null(): void {
+ $this->assertSame('', $this->make_survey(['thankspage' => null])->thankspage());
+ }
+
+ /**
+ * Asserts thankhead() returns the stored value when set.
+ *
+ * @covers \mod_questionnaire\local\survey\survey::thankhead
+ */
+ public function test_thankhead_returns_value_when_set(): void {
+ $this->assertEquals('Thank you!', $this->make_survey(['thankhead' => 'Thank you!'])->thankhead());
+ }
+
+ /**
+ * Asserts thankbody() returns an empty string when the record value is null.
+ *
+ * @covers \mod_questionnaire\local\survey\survey::thankbody
+ */
+ public function test_thankbody_returns_empty_when_null(): void {
+ $this->assertSame('', $this->make_survey(['thankbody' => null])->thankbody());
+ }
+
+ /**
+ * Asserts email() returns an empty string when the record value is null.
+ *
+ * @covers \mod_questionnaire\local\survey\survey::email
+ */
+ public function test_email_returns_empty_when_null(): void {
+ $this->assertSame('', $this->make_survey(['email' => null])->email());
+ }
+
+ /**
+ * Asserts email() returns the stored address when set.
+ *
+ * @covers \mod_questionnaire\local\survey\survey::email
+ */
+ public function test_email_returns_value_when_set(): void {
+ $this->assertEquals('a@b.com', $this->make_survey(['email' => 'a@b.com'])->email());
+ }
+
+ /**
+ * Asserts raw_field() returns the persistent value for the named column
+ * (the package-private hook used by the feedback domain class).
+ *
+ * @covers \mod_questionnaire\local\survey\survey::raw_field
+ */
+ public function test_raw_field_returns_persistent_value(): void {
+ $survey = $this->make_survey(['feedbacksections' => 3, 'charttype' => 'radar']);
+ $this->assertSame(3, (int) $survey->raw_field('feedbacksections'));
+ $this->assertSame('radar', $survey->raw_field('charttype'));
+ $this->assertNull($survey->raw_field('feedbacknotes'));
+ }
+
+ // Tests for boolean state methods.
+
+ /**
+ * Asserts is_public() returns false for a private-realm survey.
+ *
+ * @covers \mod_questionnaire\local\survey\survey::is_public
+ */
+ public function test_is_public_false_when_private(): void {
+ $this->assertFalse($this->make_survey(['realm' => 'private'])->is_public());
+ }
+
+ /**
+ * Asserts is_public() returns true for a public-realm survey.
+ *
+ * @covers \mod_questionnaire\local\survey\survey::is_public
+ */
+ public function test_is_public_true_when_public(): void {
+ $this->assertTrue($this->make_survey(['realm' => 'public'])->is_public());
+ }
+
+ /**
+ * Asserts is_template() returns false for a private-realm survey.
+ *
+ * @covers \mod_questionnaire\local\survey\survey::is_template
+ */
+ public function test_is_template_false_when_private(): void {
+ $this->assertFalse($this->make_survey(['realm' => 'private'])->is_template());
+ }
+
+ /**
+ * Asserts is_template() returns true for a template-realm survey.
+ *
+ * @covers \mod_questionnaire\local\survey\survey::is_template
+ */
+ public function test_is_template_true_when_template(): void {
+ $this->assertTrue($this->make_survey(['realm' => 'template'])->is_template());
+ }
+
+ /**
+ * Asserts is_public_master() returns true when public and the course id matches.
+ *
+ * @covers \mod_questionnaire\local\survey\survey::is_public_master
+ */
+ public function test_is_public_master_true_when_public_and_owning_course(): void {
+ $survey = $this->make_survey(['realm' => 'public', 'courseid' => 5]);
+ $this->assertTrue($survey->is_public_master(5));
+ }
+
+ /**
+ * Asserts is_public_master() returns false when the survey is private.
+ *
+ * @covers \mod_questionnaire\local\survey\survey::is_public_master
+ */
+ public function test_is_public_master_false_when_private(): void {
+ $survey = $this->make_survey(['realm' => 'private', 'courseid' => 5]);
+ $this->assertFalse($survey->is_public_master(5));
+ }
+
+ /**
+ * Asserts is_public_master() returns false when the course id does not match.
+ *
+ * @covers \mod_questionnaire\local\survey\survey::is_public_master
+ */
+ public function test_is_public_master_false_when_different_course(): void {
+ $survey = $this->make_survey(['realm' => 'public', 'courseid' => 5]);
+ $this->assertFalse($survey->is_public_master(99));
+ }
+
+ /**
+ * Asserts is_owned_by_course() returns true when the course id matches.
+ *
+ * @covers \mod_questionnaire\local\survey\survey::is_owned_by_course
+ */
+ public function test_is_owned_by_course_true_when_same(): void {
+ $this->assertTrue($this->make_survey(['courseid' => 7])->is_owned_by_course(7));
+ }
+
+ /**
+ * Asserts is_owned_by_course() returns false when the course id does not match.
+ *
+ * @covers \mod_questionnaire\local\survey\survey::is_owned_by_course
+ */
+ public function test_is_owned_by_course_false_when_different(): void {
+ $this->assertFalse($this->make_survey(['courseid' => 7])->is_owned_by_course(8));
+ }
+
+ // Tests for to_stdclass().
+
+ /**
+ * Asserts to_stdclass() returns a stdClass with the expected field values.
+ *
+ * @covers \mod_questionnaire\local\survey\survey::to_stdclass
+ */
+ public function test_to_stdclass_contains_expected_fields(): void {
+ $obj = $this->make_survey(['title' => 'T', 'realm' => 'public'])->to_stdclass();
+ $this->assertInstanceOf(\stdClass::class, $obj);
+ $this->assertEquals('T', $obj->title);
+ $this->assertEquals('public', $obj->realm);
+ }
+
+ // Tests for static position helpers (pure, no DB).
+
+ /**
+ * Asserts get_parent_positions() returns an empty array when no questions have dependencies.
+ *
+ * @covers \mod_questionnaire\local\survey\survey::get_parent_positions
+ */
+ public function test_get_parent_positions_empty_when_no_deps(): void {
+ $questions = [
+ 1 => $this->make_stub_question(1, 1),
+ 2 => $this->make_stub_question(2, 2),
+ ];
+ $this->assertSame([], survey::get_parent_positions($questions));
+ }
+
+ /**
+ * Asserts get_parent_positions() maps child id to parent position.
+ *
+ * @covers \mod_questionnaire\local\survey\survey::get_parent_positions
+ */
+ public function test_get_parent_positions_returns_parent_position(): void {
+ $dep = (object)['dependquestionid' => 1, 'dependchoiceid' => 1, 'dependlogic' => 1];
+ $questions = [
+ 1 => $this->make_stub_question(1, 5),
+ 2 => $this->make_stub_question(2, 10, [$dep]),
+ ];
+ $result = survey::get_parent_positions($questions);
+ $this->assertArrayHasKey(2, $result);
+ $this->assertEquals(5, $result[2]);
+ }
+
+ /**
+ * Asserts get_parent_positions() uses the highest parent position when a child has multiple parents.
+ *
+ * @covers \mod_questionnaire\local\survey\survey::get_parent_positions
+ */
+ public function test_get_parent_positions_highest_parent_wins(): void {
+ $dep1 = (object)['dependquestionid' => 1, 'dependchoiceid' => 1, 'dependlogic' => 1];
+ $dep2 = (object)['dependquestionid' => 2, 'dependchoiceid' => 1, 'dependlogic' => 1];
+ $questions = [
+ 1 => $this->make_stub_question(1, 3),
+ 2 => $this->make_stub_question(2, 7),
+ 3 => $this->make_stub_question(3, 10, [$dep1, $dep2]),
+ ];
+ $result = survey::get_parent_positions($questions);
+ $this->assertEquals(7, $result[3]);
+ }
+
+ /**
+ * Asserts get_child_positions() returns an empty array when no questions have dependencies.
+ *
+ * @covers \mod_questionnaire\local\survey\survey::get_child_positions
+ */
+ public function test_get_child_positions_empty_when_no_deps(): void {
+ $questions = [
+ 1 => $this->make_stub_question(1, 1),
+ 2 => $this->make_stub_question(2, 2),
+ ];
+ $this->assertSame([], survey::get_child_positions($questions));
+ }
+
+ /**
+ * Asserts get_child_positions() maps parent id to child position.
+ *
+ * @covers \mod_questionnaire\local\survey\survey::get_child_positions
+ */
+ public function test_get_child_positions_returns_child_position(): void {
+ $dep = (object)['dependquestionid' => 1, 'dependchoiceid' => 1, 'dependlogic' => 1];
+ $questions = [
+ 1 => $this->make_stub_question(1, 5),
+ 2 => $this->make_stub_question(2, 10, [$dep]),
+ ];
+ $result = survey::get_child_positions($questions);
+ $this->assertArrayHasKey(1, $result);
+ $this->assertEquals(10, $result[1]);
+ }
+
+ /**
+ * Asserts get_child_positions() uses the lowest child position when a parent has multiple children.
+ *
+ * @covers \mod_questionnaire\local\survey\survey::get_child_positions
+ */
+ public function test_get_child_positions_lowest_child_wins(): void {
+ $dep = (object)['dependquestionid' => 1, 'dependchoiceid' => 1, 'dependlogic' => 1];
+ $questions = [
+ 1 => $this->make_stub_question(1, 2),
+ 2 => $this->make_stub_question(2, 5, [$dep]),
+ 3 => $this->make_stub_question(3, 3, [$dep]),
+ ];
+ $result = survey::get_child_positions($questions);
+ $this->assertEquals(3, $result[1]);
+ }
+
+ // DB-backed tests.
+
+ /**
+ * Asserts from_sid() returns a zero-id survey when the given sid does not exist.
+ *
+ * @covers \mod_questionnaire\local\survey\survey::from_sid
+ */
+ public function test_from_sid_returns_empty_survey_for_nonexistent_sid(): void {
+ $survey = survey::from_sid(0);
+ $this->assertEquals(0, $survey->id());
+ }
+
+ /**
+ * Asserts from_sid() returns a populated survey for a real survey id.
+ *
+ * @covers \mod_questionnaire\local\survey\survey::from_sid
+ */
+ public function test_from_sid_returns_survey_for_real_sid(): void {
+ $this->resetAfterTest();
+ $this->setAdminUser();
+ $course = $this->getDataGenerator()->create_course();
+ $generator = $this->getDataGenerator()->get_plugin_generator('mod_questionnaire');
+ $questionnaire = $generator->create_instance(['course' => $course->id]);
+
+ $sid = $questionnaire->surveyid();
+ $this->assertGreaterThan(0, $sid);
+
+ $survey = survey::from_sid($sid);
+ $this->assertEquals($sid, $survey->id());
+ }
+
+ /**
+ * Asserts delete_pagebreaks() removes soft-deleted pagebreak rows while leaving other rows intact.
+ *
+ * @covers \mod_questionnaire\local\survey\survey::delete_pagebreaks
+ */
+ public function test_delete_pagebreaks_removes_deleted_pagebreak_questions(): void {
+ global $DB;
+
+ $this->resetAfterTest();
+ $this->setAdminUser();
+ $course = $this->getDataGenerator()->create_course();
+ $generator = $this->getDataGenerator()->get_plugin_generator('mod_questionnaire');
+ $questionnaire = $generator->create_instance(['course' => $course->id]);
+ $sid = $questionnaire->surveyid();
+
+ // Insert a soft-deleted pagebreak question.
+ $pbid = $DB->insert_record('questionnaire_question', [
+ 'surveyid' => $sid,
+ 'typeid' => QUESPAGEBREAK,
+ 'position' => 1,
+ 'deleted' => time(),
+ 'content' => '',
+ ]);
+
+ // Insert a non-deleted regular question that must not be removed.
+ $regularid = $DB->insert_record('questionnaire_question', [
+ 'surveyid' => $sid,
+ 'typeid' => QUESYESNO,
+ 'position' => 2,
+ 'deleted' => null,
+ 'content' => 'Q?',
+ ]);
+
+ survey::delete_pagebreaks($sid);
+
+ $this->assertFalse($DB->record_exists('questionnaire_question', ['id' => $pbid]));
+ $this->assertTrue($DB->record_exists('questionnaire_question', ['id' => $regularid]));
+ }
+
+ // Tests for survey listing methods.
+
+ /**
+ * Asserts get_private_questionnaires() returns an entry keyed "private-{sid}" for a private survey.
+ *
+ * @covers \mod_questionnaire\local\survey\survey::get_private_questionnaires
+ */
+ public function test_get_private_questionnaires_returns_entry_for_course_survey(): void {
+ $this->resetAfterTest();
+ $this->setAdminUser();
+ $course = $this->getDataGenerator()->create_course();
+ $generator = $this->getDataGenerator()->get_plugin_generator('mod_questionnaire');
+ $questionnaire = $generator->create_instance(['course' => $course->id]);
+ $sid = $questionnaire->surveyid();
+
+ $result = survey::get_private_questionnaires($course->id);
+
+ $this->assertArrayHasKey('private-' . $sid, $result);
+ $this->assertNotEmpty($result['private-' . $sid]);
+ }
+
+ /**
+ * Asserts get_public_questionnaires() includes other-course public surveys and excludes the current course's own.
+ *
+ * @covers \mod_questionnaire\local\survey\survey::get_public_questionnaires
+ */
+ public function test_get_public_questionnaires_excludes_current_course(): void {
+ $this->resetAfterTest();
+ $this->setAdminUser();
+ $course1 = $this->getDataGenerator()->create_course();
+ $course2 = $this->getDataGenerator()->create_course();
+ $generator = $this->getDataGenerator()->get_plugin_generator('mod_questionnaire');
+
+ $q1 = $generator->create_instance(['course' => $course1->id]);
+ $generator->create_content($q1, ['realm' => 'public']);
+ $sid1 = $q1->surveyid();
+
+ $q2 = $generator->create_instance(['course' => $course2->id]);
+ $generator->create_content($q2, ['realm' => 'public']);
+ $sid2 = $q2->surveyid();
+
+ $result = survey::get_public_questionnaires($course1->id);
+
+ $this->assertArrayNotHasKey('public-' . $sid1, $result);
+ $this->assertArrayHasKey('public-' . $sid2, $result);
+ }
+
+ /**
+ * Asserts get_template_questionnaires() returns an entry keyed "template-{sid}" for a template survey.
+ *
+ * @covers \mod_questionnaire\local\survey\survey::get_template_questionnaires
+ */
+ public function test_get_template_questionnaires_returns_template_survey(): void {
+ $this->resetAfterTest();
+ $this->setAdminUser();
+ $course = $this->getDataGenerator()->create_course();
+ $generator = $this->getDataGenerator()->get_plugin_generator('mod_questionnaire');
+
+ $questionnaire = $generator->create_instance(['course' => $course->id]);
+ $generator->create_content($questionnaire, ['realm' => 'template']);
+ $sid = $questionnaire->surveyid();
+
+ $result = survey::get_template_questionnaires($course->id);
+
+ $this->assertArrayHasKey('template-' . $sid, $result);
+ $this->assertNotEmpty($result['template-' . $sid]);
+ }
+
+ // Tests covering the update_settings allowlist persistence path.
+
+ /**
+ * Asserts update_settings() persists each allowlisted field onto the survey row.
+ *
+ * @covers \mod_questionnaire\local\survey\survey::update_settings
+ */
+ public function test_update_settings_persists_allowlisted_fields(): void {
+ global $DB;
+
+ $this->resetAfterTest();
+ $this->setAdminUser();
+ $course = $this->getDataGenerator()->create_course();
+ $generator = $this->getDataGenerator()->get_plugin_generator('mod_questionnaire');
+ $questionnaire = $generator->create_instance(['course' => $course->id]);
+ $sid = $questionnaire->surveyid();
+
+ $survey = survey::from_sid($sid);
+ $result = $survey->update_settings([
+ 'subtitle' => 'New subtitle',
+ 'thankhead' => 'New thank head',
+ 'email' => 'sup@example.org',
+ ]);
+
+ $this->assertSame($sid, $result);
+ $row = $DB->get_record('questionnaire_survey', ['id' => $sid]);
+ $this->assertEquals('New subtitle', $row->subtitle);
+ $this->assertEquals('New thank head', $row->thankhead);
+ $this->assertEquals('sup@example.org', $row->email);
+ }
+
+ /**
+ * Asserts update_settings() rejects unknown / non-allowlisted fields.
+ *
+ * @covers \mod_questionnaire\local\survey\survey::update_settings
+ */
+ public function test_update_settings_rejects_unknown_field(): void {
+ $this->resetAfterTest();
+ $this->setAdminUser();
+ $course = $this->getDataGenerator()->create_course();
+ $generator = $this->getDataGenerator()->get_plugin_generator('mod_questionnaire');
+ $questionnaire = $generator->create_instance(['course' => $course->id]);
+
+ $survey = survey::from_sid($questionnaire->surveyid());
+
+ $this->expectException(\coding_exception::class);
+ $survey->update_settings(['id' => 999]);
+ }
+
+ /**
+ * Asserts update_settings() rejects a field that is not in the persistent's properties
+ * (e.g. someone passing a form-only key like 'sid').
+ *
+ * @covers \mod_questionnaire\local\survey\survey::update_settings
+ */
+ public function test_update_settings_rejects_form_metadata_field(): void {
+ $this->resetAfterTest();
+ $this->setAdminUser();
+ $course = $this->getDataGenerator()->create_course();
+ $generator = $this->getDataGenerator()->get_plugin_generator('mod_questionnaire');
+ $questionnaire = $generator->create_instance(['course' => $course->id]);
+
+ $survey = survey::from_sid($questionnaire->surveyid());
+
+ $this->expectException(\coding_exception::class);
+ $survey->update_settings(['sid' => $questionnaire->surveyid()]);
+ }
+
+ /**
+ * Asserts update_settings() returns false when a present 'name' / 'title' / 'realm' is empty.
+ *
+ * @covers \mod_questionnaire\local\survey\survey::update_settings
+ */
+ public function test_update_settings_returns_false_for_empty_required_field(): void {
+ $this->resetAfterTest();
+ $this->setAdminUser();
+ $course = $this->getDataGenerator()->create_course();
+ $generator = $this->getDataGenerator()->get_plugin_generator('mod_questionnaire');
+ $questionnaire = $generator->create_instance(['course' => $course->id]);
+
+ $survey = survey::from_sid($questionnaire->surveyid());
+ $this->assertFalse($survey->update_settings(['name' => '']));
+ $this->assertFalse($survey->update_settings(['title' => '']));
+ $this->assertFalse($survey->update_settings(['realm' => '']));
+ }
+
+ /**
+ * Asserts update_settings() returns false when renaming to a value that collides with another survey.
+ *
+ * @covers \mod_questionnaire\local\survey\survey::update_settings
+ */
+ public function test_update_settings_returns_false_for_duplicate_name(): void {
+ $this->resetAfterTest();
+ $this->setAdminUser();
+ $course = $this->getDataGenerator()->create_course();
+ $generator = $this->getDataGenerator()->get_plugin_generator('mod_questionnaire');
+ $q1 = $generator->create_instance(['course' => $course->id]);
+ $q2 = $generator->create_instance(['course' => $course->id]);
+
+ $survey1 = survey::from_sid($q1->surveyid());
+ $survey2 = survey::from_sid($q2->surveyid());
+
+ $survey1->update_settings(['name' => 'shared-name']);
+ $this->assertFalse($survey2->update_settings(['name' => 'shared-name']));
+ }
+
+ /**
+ * Asserts update_settings() does NOT silently mutate fields that were not supplied
+ * (only the named keys are written).
+ *
+ * @covers \mod_questionnaire\local\survey\survey::update_settings
+ */
+ public function test_update_settings_does_not_touch_unsupplied_fields(): void {
+ global $DB;
+
+ $this->resetAfterTest();
+ $this->setAdminUser();
+ $course = $this->getDataGenerator()->create_course();
+ $generator = $this->getDataGenerator()->get_plugin_generator('mod_questionnaire');
+ $questionnaire = $generator->create_instance(['course' => $course->id]);
+ $sid = $questionnaire->surveyid();
+ $originaltitle = $DB->get_field('questionnaire_survey', 'title', ['id' => $sid]);
+
+ survey::from_sid($sid)->update_settings(['subtitle' => 'New subtitle']);
+
+ $this->assertEquals($originaltitle, $DB->get_field('questionnaire_survey', 'title', ['id' => $sid]));
+ $this->assertEquals('New subtitle', $DB->get_field('questionnaire_survey', 'subtitle', ['id' => $sid]));
+ }
+
+ /**
+ * Asserts update_settings() rejects feedback-domain fields: they live on
+ * {@see feedback::UPDATABLE_FIELDS} and must go through feedback::update_settings().
+ *
+ * @covers \mod_questionnaire\local\survey\survey::update_settings
+ */
+ public function test_update_settings_rejects_feedback_fields(): void {
+ $this->resetAfterTest();
+ $this->setAdminUser();
+ $course = $this->getDataGenerator()->create_course();
+ $generator = $this->getDataGenerator()->get_plugin_generator('mod_questionnaire');
+ $questionnaire = $generator->create_instance(['course' => $course->id]);
+
+ $survey = survey::from_sid($questionnaire->surveyid());
+
+ $this->expectException(\coding_exception::class);
+ $survey->update_settings(['feedbacksections' => 3]);
+ }
+
+ // Tests for has_required() (pure, no DB).
+
+ /**
+ * Asserts has_required() returns false when there are no questions.
+ *
+ * @covers \mod_questionnaire\local\survey\survey::has_required
+ */
+ public function test_has_required_false_when_no_questions(): void {
+ $this->assertFalse($this->make_survey()->has_required());
+ $this->assertFalse($this->make_survey()->has_required(2));
+ }
+
+ /**
+ * Asserts has_required() returns true across all sections when any question is required.
+ *
+ * @covers \mod_questionnaire\local\survey\survey::has_required
+ */
+ public function test_has_required_true_across_all_sections(): void {
+ $survey = $this->make_survey();
+ $survey->set_questions([
+ 1 => $this->make_required_stub(false),
+ 2 => $this->make_required_stub(true),
+ ]);
+ $this->assertTrue($survey->has_required());
+ }
+
+ /**
+ * Asserts has_required() returns false across all sections when no question is required.
+ *
+ * @covers \mod_questionnaire\local\survey\survey::has_required
+ */
+ public function test_has_required_false_across_all_sections(): void {
+ $survey = $this->make_survey();
+ $survey->set_questions([
+ 1 => $this->make_required_stub(false),
+ 2 => $this->make_required_stub(false),
+ ]);
+ $this->assertFalse($survey->has_required());
+ }
+
+ /**
+ * Asserts has_required() scoped to a section only inspects that section.
+ *
+ * @covers \mod_questionnaire\local\survey\survey::has_required
+ */
+ public function test_has_required_per_section(): void {
+ $survey = $this->make_survey();
+ $survey->set_questions_by_sec([
+ 1 => [$this->make_required_stub(false)],
+ 2 => [$this->make_required_stub(true)],
+ ]);
+ $survey->set_questions([
+ 1 => $this->make_required_stub(false),
+ 2 => $this->make_required_stub(true),
+ ]);
+ $this->assertFalse($survey->has_required(1));
+ $this->assertTrue($survey->has_required(2));
+ $this->assertFalse($survey->has_required(99));
+ }
+
+ // Tests for get_all_file_areas().
+
+ /**
+ * Build a minimal question stub that supports id().
+ *
+ * @param int $id
+ * @return object
+ */
+ private function make_question_stub(int $id): object {
+ return new class ($id) {
+ /** @var int */
+ public int $id;
+ /**
+ * Constructor.
+ * @param int $id
+ */
+ public function __construct(int $id) {
+ $this->id = $id;
+ }
+ /**
+ * Return the question id.
+ * @return int
+ */
+ public function id(): int {
+ return $this->id;
+ }
+ };
+ }
+
+ /**
+ * Asserts get_all_file_areas() returns the basic area keys when no feedback sections exist.
+ *
+ * @covers \mod_questionnaire\local\survey\survey::get_all_file_areas
+ */
+ public function test_get_all_file_areas_basic(): void {
+ $this->resetAfterTest();
+ $sid = 1;
+ $survey = $this->make_survey(['id' => $sid]);
+ $survey->set_questions([10 => $this->make_question_stub(10)]);
+
+ $areas = $survey->get_all_file_areas();
+ $this->assertSame($sid, $areas['info']);
+ $this->assertSame($sid, $areas['thankbody']);
+ $this->assertSame($sid, $areas['feedbacknotes']);
+ $this->assertContains(10, $areas['question']);
+ $this->assertArrayNotHasKey('sectionheading', $areas);
+ $this->assertArrayNotHasKey('feedback', $areas);
+ }
+
+ /**
+ * Asserts get_all_file_areas() includes sectionheading and feedback areas when fb_sections exist.
+ *
+ * @covers \mod_questionnaire\local\survey\survey::get_all_file_areas
+ */
+ public function test_get_all_file_areas_with_feedback_sections(): void {
+ global $DB;
+ $this->resetAfterTest();
+ $sid = 12345;
+ $survey = $this->make_survey(['id' => $sid]);
+ $survey->set_questions([10 => $this->make_question_stub(10)]);
+
+ $sectionid = $DB->insert_record('questionnaire_fb_sections', (object)[
+ 'surveyid' => $sid, 'section' => 1, 'scorecalculation' => null,
+ 'sectionlabel' => 'S1', 'sectionheading' => '', 'sectionheadingformat' => FORMAT_HTML,
+ ]);
+ $feedbackid = $DB->insert_record('questionnaire_feedback', (object)[
+ 'sectionid' => $sectionid, 'feedbacklabel' => '', 'feedbacktext' => '',
+ 'feedbacktextformat' => FORMAT_HTML, 'minscore' => 0, 'maxscore' => 100,
+ ]);
+
+ $areas = $survey->get_all_file_areas();
+ $this->assertArrayHasKey('sectionheading', $areas);
+ $this->assertContains((int)$sectionid, $areas['sectionheading']);
+ $this->assertArrayHasKey('feedback', $areas);
+ $this->assertContains((int)$feedbackid, $areas['feedback']);
+ }
+}
diff --git a/tests/local/survey/survey_testable.php b/tests/local/survey/survey_testable.php
new file mode 100644
index 000000000..04170b412
--- /dev/null
+++ b/tests/local/survey/survey_testable.php
@@ -0,0 +1,62 @@
+.
+
+namespace mod_questionnaire\local\survey;
+
+use mod_questionnaire\local\db\survey_record;
+
+/**
+ * Testable subclass of survey that bypasses the real constructor.
+ *
+ * Allows injecting a survey_record and question arrays directly so
+ * business-logic methods can be unit tested without a real course module.
+ *
+ * @package mod_questionnaire
+ * @copyright 2025 Mike Churchward (mike.churchward@poetopensource.org)
+ * @author Mike Churchward
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class survey_testable extends survey {
+ /**
+ * Construct with injected record, bypassing the real survey constructor.
+ *
+ * @param survey_record $surveyrecord
+ */
+ public function __construct(survey_record $surveyrecord) {
+ $this->surveyrecord = $surveyrecord;
+ $this->context = null;
+ $this->questions = [];
+ $this->questionsbysec = [];
+ }
+
+ /**
+ * Inject question objects for unit tests (bypasses DB loading).
+ *
+ * @param array $questions Keyed by question id.
+ */
+ public function set_questions(array $questions): void {
+ $this->questions = $questions;
+ }
+
+ /**
+ * Inject the questions-by-section map for unit tests.
+ *
+ * @param array $questionsbysec Array of question object arrays, keyed by 1-based section number.
+ */
+ public function set_questions_by_sec(array $questionsbysec): void {
+ $this->questionsbysec = $questionsbysec;
+ }
+}
diff --git a/tests/local/survey/survey_view_builder_test.php b/tests/local/survey/survey_view_builder_test.php
new file mode 100644
index 000000000..54ec0c183
--- /dev/null
+++ b/tests/local/survey/survey_view_builder_test.php
@@ -0,0 +1,494 @@
+.
+
+/**
+ * Unit tests for mod_questionnaire\local\survey\survey_view_builder.
+ *
+ * @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\local\survey;
+
+use mod_questionnaire\output\viewpage;
+use mod_questionnaire\questionnaire;
+
+/**
+ * Unit tests for mod_questionnaire\local\survey\survey_view_builder.
+ *
+ * @group mod_questionnaire
+ * @covers \mod_questionnaire\local\survey\survey_view_builder
+ */
+final class survey_view_builder_test extends \advanced_testcase {
+ /**
+ * Build a course + questionnaire with one yes/no question and return both the
+ * domain object and the enrolled student id.
+ *
+ * @param string $realm Survey realm (defaults to 'private').
+ * @return array [questionnaire, student id]
+ */
+ private function build_fixture(string $realm = 'private'): array {
+ global $DB;
+ $generator = $this->getDataGenerator();
+ $course = $generator->create_course();
+ $plugin = $generator->get_plugin_generator('mod_questionnaire');
+ $instance = $plugin->create_test_questionnaire(
+ $course,
+ QUESYESNO,
+ ['content' => 'Yes or no?'],
+ []
+ );
+
+ $student = $generator->create_user();
+ $studentrole = $DB->get_record('role', ['shortname' => 'student'], '*', MUST_EXIST);
+ $generator->enrol_user($student->id, $course->id, $studentrole->id);
+
+ return [$instance, (int) $student->id];
+ }
+
+ /**
+ * Expose the protected stdClass that backs the viewpage so tests can read back
+ * the keys the renderer added.
+ *
+ * @param viewpage $page
+ * @return \stdClass
+ */
+ private function page_data(viewpage $page): \stdClass {
+ global $PAGE;
+ return $page->export_for_template($PAGE->get_renderer('mod_questionnaire'));
+ }
+
+ /**
+ * build_view() writes an access-denied notification when access is blocked
+ * (template realm, current user is not the survey owner).
+ */
+ public function test_build_view_writes_access_denied(): void {
+ global $DB, $PAGE;
+ $this->resetAfterTest();
+ $this->setAdminUser();
+
+ [$instance, $studentid] = $this->build_fixture();
+ // Flip the survey realm to template so user_access_messages reports "not viewable".
+ $DB->set_field(
+ 'questionnaire_survey',
+ 'realm',
+ 'template',
+ ['id' => $instance->surveyid()]
+ );
+ $instance = questionnaire::from_instanceid($instance->id());
+
+ $renderer = $PAGE->get_renderer('mod_questionnaire');
+ $page = new viewpage();
+ (new survey_view_builder($renderer, $page))->build_view($instance, $studentid);
+
+ $data = $this->page_data($page);
+ $this->assertObjectHasProperty('notifications', $data);
+ $this->assertStringContainsString(
+ get_string('templatenotviewable', 'questionnaire'),
+ $data->notifications
+ );
+ $this->assertObjectNotHasProperty('formstart', $data);
+ }
+
+ /**
+ * build_view() falls through to build_survey_form when access is allowed, populating
+ * the standard form keys.
+ */
+ public function test_build_view_populates_survey_form_when_allowed(): void {
+ global $PAGE;
+ $this->resetAfterTest();
+ $this->setAdminUser();
+
+ [$instance, $studentid] = $this->build_fixture();
+ $this->setUser($studentid);
+ $instance = questionnaire::from_instanceid($instance->id());
+
+ $renderer = $PAGE->get_renderer('mod_questionnaire');
+ $page = new viewpage();
+ (new survey_view_builder($renderer, $page))->build_view($instance, $studentid);
+
+ $data = $this->page_data($page);
+ $this->assertObjectHasProperty('formstart', $data);
+ $this->assertObjectHasProperty('controlbuttons', $data);
+ $this->assertObjectHasProperty('formend', $data);
+ }
+
+ /**
+ * build_survey_form() returns null and populates formstart/controlbuttons/formend
+ * when questions exist.
+ */
+ public function test_build_survey_form_with_questions(): void {
+ global $PAGE;
+ $this->resetAfterTest();
+ $this->setAdminUser();
+
+ [$instance, $studentid] = $this->build_fixture();
+ $this->setUser($studentid);
+ $instance = questionnaire::from_instanceid($instance->id());
+
+ $renderer = $PAGE->get_renderer('mod_questionnaire');
+ $page = new viewpage();
+ $result = (new survey_view_builder($renderer, $page))
+ ->build_survey_form($instance, $studentid, $studentid);
+
+ $this->assertNull($result);
+ $data = $this->page_data($page);
+ $this->assertObjectHasProperty('formstart', $data);
+ $this->assertObjectHasProperty('controlbuttons', $data);
+ $this->assertObjectHasProperty('formend', $data);
+ }
+
+ /**
+ * build_survey_form() with the survey title set writes it to the page.
+ */
+ public function test_build_survey_form_writes_title(): void {
+ global $DB, $PAGE;
+ $this->resetAfterTest();
+ $this->setAdminUser();
+
+ [$instance, $studentid] = $this->build_fixture();
+ $DB->set_field(
+ 'questionnaire_survey',
+ 'title',
+ 'My Survey Title',
+ ['id' => $instance->surveyid()]
+ );
+ $this->setUser($studentid);
+ $instance = questionnaire::from_instanceid($instance->id());
+
+ $renderer = $PAGE->get_renderer('mod_questionnaire');
+ $page = new viewpage();
+ (new survey_view_builder($renderer, $page))
+ ->build_survey_form($instance, $studentid, $studentid);
+
+ $data = $this->page_data($page);
+ $this->assertObjectHasProperty('title', $data);
+ $this->assertStringContainsString('My Survey Title', $data->title);
+ }
+
+ /**
+ * handle_submit_action() short-circuits (returns null) when the user has already
+ * walked past the final page (formdata->end == 1).
+ */
+ public function test_handle_submit_action_short_circuits_when_past_end(): void {
+ global $PAGE;
+ $this->resetAfterTest();
+ $this->setAdminUser();
+
+ [$instance, $studentid] = $this->build_fixture();
+ $this->setUser($studentid);
+ $instance = questionnaire::from_instanceid($instance->id());
+
+ $renderer = $PAGE->get_renderer('mod_questionnaire');
+ $page = new viewpage();
+
+ $svr = new survey_view_builder($renderer, $page);
+ $formdata = (object)['sec' => 1, 'rid' => 0, 'end' => 1];
+ $method = new \ReflectionMethod($svr, 'handle_submit_action');
+ $method->setAccessible(true);
+
+ $this->assertNull($method->invoke($svr, $instance, $formdata, (int)$studentid));
+ }
+
+ /**
+ * handle_resume_action() persists the in-progress response and posts a
+ * "progress saved" notification to the page (delegating to goto_saved).
+ */
+ public function test_handle_resume_action_writes_savedprogress(): void {
+ global $PAGE;
+ $this->resetAfterTest();
+ $this->setAdminUser();
+
+ [$instance, $studentid] = $this->build_fixture();
+ $this->setUser($studentid);
+ $instance = questionnaire::from_instanceid($instance->id());
+
+ $renderer = $PAGE->get_renderer('mod_questionnaire');
+ $page = new viewpage();
+
+ $svr = new survey_view_builder($renderer, $page);
+ $formdata = (object)['sec' => 1, 'rid' => 0];
+ $method = new \ReflectionMethod($svr, 'handle_resume_action');
+ $method->setAccessible(true);
+ $method->invoke($svr, $instance, $formdata, (int)$studentid);
+
+ $data = $this->page_data($page);
+ $this->assertObjectHasProperty('notifications', $data);
+ $this->assertStringContainsString('progress has been saved', $data->notifications);
+ $this->assertNotEmpty($formdata->rid);
+ }
+
+ /**
+ * handle_next_action() advances formdata->sec to the next section on success.
+ */
+ public function test_handle_next_action_advances_to_next_section(): void {
+ global $PAGE;
+ $this->resetAfterTest();
+ $this->setAdminUser();
+
+ [$instance, $studentid] = $this->build_fixture();
+ $this->setUser($studentid);
+ $instance = questionnaire::from_instanceid($instance->id());
+
+ $renderer = $PAGE->get_renderer('mod_questionnaire');
+ $page = new viewpage();
+
+ $svr = new survey_view_builder($renderer, $page);
+ $formdata = (object)['sec' => 1, 'rid' => 0, 'next' => 'Next'];
+ $method = new \ReflectionMethod($svr, 'handle_next_action');
+ $method->setAccessible(true);
+ $msg = $method->invoke($svr, $instance, $formdata, (int)$studentid, 1);
+
+ $this->assertSame('', $msg);
+ $this->assertSame(2, $formdata->sec);
+ }
+
+ /**
+ * handle_submit_action() returns the validation error and refreshes formdata->rid
+ * when the user tries to submit a page with a required question left blank.
+ */
+ public function test_handle_submit_action_returns_error_on_missing_required(): void {
+ global $DB, $PAGE;
+ $this->resetAfterTest();
+ $this->setAdminUser();
+
+ [$instance, $studentid] = $this->build_fixture();
+ // Mark the only question on the survey as required so check_format flags it as missing.
+ $qid = $DB->get_field('questionnaire_question', 'id', ['surveyid' => $instance->surveyid()]);
+ $DB->set_field('questionnaire_question', 'required', 'y', ['id' => $qid]);
+ $this->setUser($studentid);
+ $instance = questionnaire::from_instanceid($instance->id());
+
+ $renderer = $PAGE->get_renderer('mod_questionnaire');
+ $page = new viewpage();
+
+ $svr = new survey_view_builder($renderer, $page);
+ $formdata = (object)['sec' => 1, 'rid' => 0, 'submit' => 'Submit Survey'];
+ $method = new \ReflectionMethod($svr, 'handle_submit_action');
+ $method->setAccessible(true);
+ $msg = $method->invoke($svr, $instance, $formdata, (int)$studentid);
+
+ $this->assertNotEmpty($msg);
+ $this->assertNotEmpty($formdata->rid);
+ }
+
+ /**
+ * handle_next_action() returns the validation error, clears formdata->next, and
+ * refreshes formdata->rid when the user tries to advance past a required question.
+ */
+ public function test_handle_next_action_returns_error_on_missing_required(): void {
+ global $DB, $PAGE;
+ $this->resetAfterTest();
+ $this->setAdminUser();
+
+ [$instance, $studentid] = $this->build_fixture();
+ $qid = $DB->get_field('questionnaire_question', 'id', ['surveyid' => $instance->surveyid()]);
+ $DB->set_field('questionnaire_question', 'required', 'y', ['id' => $qid]);
+ $this->setUser($studentid);
+ $instance = questionnaire::from_instanceid($instance->id());
+
+ $renderer = $PAGE->get_renderer('mod_questionnaire');
+ $page = new viewpage();
+
+ $svr = new survey_view_builder($renderer, $page);
+ $formdata = (object)['sec' => 1, 'rid' => 0, 'next' => 'Next'];
+ $method = new \ReflectionMethod($svr, 'handle_next_action');
+ $method->setAccessible(true);
+ $msg = $method->invoke($svr, $instance, $formdata, (int)$studentid, 1);
+
+ $this->assertNotEmpty($msg);
+ $this->assertSame('', $formdata->next);
+ $this->assertNotEmpty($formdata->rid);
+ }
+
+ /**
+ * handle_prev_action() returns a wrong-format error, clears formdata->prev,
+ * and refreshes formdata->rid when the current page contains an invalid date.
+ *
+ * Prev only checks format (not missing-required), so we use a date question
+ * with a non-date value to trigger response_valid() == false.
+ */
+ public function test_handle_prev_action_returns_error_on_invalid_format(): void {
+ global $DB, $PAGE;
+ $this->resetAfterTest();
+ $this->setAdminUser();
+
+ $generator = $this->getDataGenerator();
+ $course = $generator->create_course();
+ $plugin = $generator->get_plugin_generator('mod_questionnaire');
+ $instance = $plugin->create_test_questionnaire(
+ $course,
+ QUESDATE,
+ ['content' => 'When?'],
+ []
+ );
+ $student = $generator->create_user();
+ $studentrole = $DB->get_record('role', ['shortname' => 'student'], '*', MUST_EXIST);
+ $generator->enrol_user($student->id, $course->id, $studentrole->id);
+ $this->setUser($student->id);
+ $instance = questionnaire::from_instanceid($instance->id());
+
+ $qid = $DB->get_field('questionnaire_question', 'id', ['surveyid' => $instance->surveyid()]);
+
+ $renderer = $PAGE->get_renderer('mod_questionnaire');
+ $page = new viewpage();
+
+ $svr = new survey_view_builder($renderer, $page);
+ $formdata = (object)['sec' => 1, 'rid' => 0, 'prev' => 'Prev', 'q' . $qid => 'not-a-date'];
+ $method = new \ReflectionMethod($svr, 'handle_prev_action');
+ $method->setAccessible(true);
+ $msg = $method->invoke($svr, $instance, $formdata, (int)$student->id);
+
+ $this->assertNotEmpty($msg);
+ $this->assertSame('', $formdata->prev);
+ $this->assertNotEmpty($formdata->rid);
+ }
+
+ /**
+ * handle_prev_action() walks back from the past-end summary by decrementing
+ * sec and clearing SESSION->end before validating the current page.
+ */
+ public function test_handle_prev_action_walks_back_from_past_end(): void {
+ global $PAGE;
+ $this->resetAfterTest();
+ $this->setAdminUser();
+
+ [$instance, $studentid] = $this->build_fixture();
+ $this->setUser($studentid);
+ $instance = questionnaire::from_instanceid($instance->id());
+
+ $renderer = $PAGE->get_renderer('mod_questionnaire');
+ $page = new viewpage();
+
+ $svr = new survey_view_builder($renderer, $page);
+ // After the user walked past the last section (sec=2 + end=1), pressing Prev should land them on sec=1
+ // with the end flag cleared.
+ $formdata = (object)['sec' => 2, 'rid' => 0, 'prev' => 'Prev', 'end' => 1];
+ $method = new \ReflectionMethod($svr, 'handle_prev_action');
+ $method->setAccessible(true);
+ $method->invoke($svr, $instance, $formdata, (int)$studentid);
+
+ $this->assertSame(0, $formdata->end);
+ }
+
+ /**
+ * print_survey_end() writes a "page X of Y" footer when auto-numbering is on and
+ * there is more than one section.
+ */
+ public function test_print_survey_end_writes_pageinfo(): void {
+ global $DB, $PAGE;
+ $this->resetAfterTest();
+ $this->setAdminUser();
+
+ [$instance, ] = $this->build_fixture();
+ // The autonum bit 2 (decimal 2 or 3) turns on page auto-numbering.
+ $DB->set_field('questionnaire', 'autonum', 3, ['id' => $instance->id()]);
+ $instance = questionnaire::from_instanceid($instance->id());
+
+ $renderer = $PAGE->get_renderer('mod_questionnaire');
+ $page = new viewpage();
+ (new survey_view_builder($renderer, $page))->print_survey_end($instance, 1, 3);
+
+ $data = $this->page_data($page);
+ $this->assertObjectHasProperty('pageinfo', $data);
+ $this->assertStringContainsString('1', $data->pageinfo);
+ }
+
+ /**
+ * print_survey_end() is a no-op when there is only one section.
+ */
+ public function test_print_survey_end_skips_single_section(): void {
+ global $DB, $PAGE;
+ $this->resetAfterTest();
+ $this->setAdminUser();
+
+ [$instance, ] = $this->build_fixture();
+ $DB->set_field('questionnaire', 'autonum', 3, ['id' => $instance->id()]);
+ $instance = questionnaire::from_instanceid($instance->id());
+
+ $renderer = $PAGE->get_renderer('mod_questionnaire');
+ $page = new viewpage();
+ (new survey_view_builder($renderer, $page))->print_survey_end($instance, 1, 1);
+
+ $data = $this->page_data($page);
+ $this->assertObjectNotHasProperty('pageinfo', $data);
+ }
+
+ /**
+ * print_survey_end() is a no-op when auto-numbering is disabled.
+ */
+ public function test_print_survey_end_skips_when_autonum_off(): void {
+ global $DB, $PAGE;
+ $this->resetAfterTest();
+ $this->setAdminUser();
+
+ [$instance, ] = $this->build_fixture();
+ $DB->set_field('questionnaire', 'autonum', 0, ['id' => $instance->id()]);
+ $instance = questionnaire::from_instanceid($instance->id());
+
+ $renderer = $PAGE->get_renderer('mod_questionnaire');
+ $page = new viewpage();
+ (new survey_view_builder($renderer, $page))->print_survey_end($instance, 1, 5);
+
+ $data = $this->page_data($page);
+ $this->assertObjectNotHasProperty('pageinfo', $data);
+ }
+
+ /**
+ * goto_saved() adds a save-progress notification and a back-to-course homelink.
+ */
+ public function test_goto_saved_writes_savedprogress_notification(): void {
+ global $PAGE;
+ $this->resetAfterTest();
+ $this->setAdminUser();
+
+ [$instance, $studentid] = $this->build_fixture();
+ $this->setUser($studentid);
+ $instance = questionnaire::from_instanceid($instance->id());
+
+ $renderer = $PAGE->get_renderer('mod_questionnaire');
+ $page = new viewpage();
+ (new survey_view_builder($renderer, $page))->goto_saved($instance);
+
+ $data = $this->page_data($page);
+ $this->assertObjectHasProperty('notifications', $data);
+ $this->assertStringContainsString('progress has been saved', $data->notifications);
+ $this->assertObjectHasProperty('respondentinfo', $data);
+ }
+
+ /**
+ * goto_thankyou() writes the thank-you title and a continue button when no thanks URL is set.
+ */
+ public function test_goto_thankyou_writes_title_and_continue(): void {
+ global $PAGE;
+ $this->resetAfterTest();
+ $this->setAdminUser();
+
+ [$instance, $studentid] = $this->build_fixture();
+ $this->setUser($studentid);
+ $instance = questionnaire::from_instanceid($instance->id());
+
+ $renderer = $PAGE->get_renderer('mod_questionnaire');
+ $page = new viewpage();
+ (new survey_view_builder($renderer, $page))->goto_thankyou($instance);
+
+ $data = $this->page_data($page);
+ $this->assertObjectHasProperty('title', $data);
+ $this->assertObjectHasProperty('continue', $data);
+ }
+}
diff --git a/tests/output/mobile_test.php b/tests/output/mobile_test.php
new file mode 100644
index 000000000..57bf272e6
--- /dev/null
+++ b/tests/output/mobile_test.php
@@ -0,0 +1,159 @@
+.
+
+/**
+ * Unit tests for the mobile WS response builder.
+ *
+ * @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;
+
+/**
+ * Tests for mod_questionnaire\output\mobile::mobile_view_activity().
+ *
+ * Asserts the shape of the WS response payload (templates / javascript /
+ * otherdata / files). Acts as a guard against accidental changes to the
+ * payload contract that the Moodle Mobile app consumes.
+ *
+ * @group mod_questionnaire
+ * @covers \mod_questionnaire\output\mobile::mobile_view_activity
+ */
+final class mobile_test extends \advanced_testcase {
+ /**
+ * Build a questionnaire with a single yes/no question owned by the current user.
+ *
+ * @return \mod_questionnaire\questionnaire
+ */
+ private function build_questionnaire(): \mod_questionnaire\questionnaire {
+ $generator = $this->getDataGenerator();
+ $course = $generator->create_course();
+ return $generator->get_plugin_generator('mod_questionnaire')->create_test_questionnaire(
+ $course,
+ QUESYESNO,
+ ['content' => 'Yes or no?'],
+ []
+ );
+ }
+
+ /**
+ * Assert the invariant payload shape emitted by mobile_view_activity().
+ *
+ * @param array $result
+ * @return void
+ */
+ private function assert_payload_shape(array $result): void {
+ $this->assertArrayHasKey('templates', $result);
+ $this->assertArrayHasKey('javascript', $result);
+ $this->assertArrayHasKey('otherdata', $result);
+ $this->assertArrayHasKey('files', $result);
+ $this->assertIsArray($result['templates']);
+ $this->assertCount(1, $result['templates']);
+ $this->assertSame('main', $result['templates'][0]['id']);
+ $this->assertIsString($result['templates'][0]['html']);
+ $this->assertIsString($result['javascript']);
+ $this->assertIsArray($result['otherdata']);
+ $this->assertNull($result['files']);
+ }
+
+ /**
+ * The default 'index' action returns a payload with the documented shape.
+ */
+ public function test_mobile_view_activity_index_returns_expected_payload_shape(): void {
+ $this->resetAfterTest();
+ $this->setAdminUser();
+
+ $generator = $this->getDataGenerator();
+ $course = $generator->create_course();
+ $questionnaire = $generator->get_plugin_generator('mod_questionnaire')
+ ->create_instance(['course' => $course->id]);
+
+ $result = mobile::mobile_view_activity([
+ 'cmid' => $questionnaire->coursemodule()->id,
+ 'action' => 'index',
+ 'appversioncode' => 44000,
+ ]);
+
+ $this->assertIsArray($result);
+ $this->assert_payload_shape($result);
+ }
+
+ /**
+ * The 'respond' action returns the same payload shape when there is no in-progress response.
+ */
+ public function test_mobile_view_activity_respond_returns_payload_shape(): void {
+ $this->resetAfterTest();
+ $this->setAdminUser();
+ $questionnaire = $this->build_questionnaire();
+
+ $result = mobile::mobile_view_activity([
+ 'cmid' => $questionnaire->coursemodule()->id,
+ 'action' => 'respond',
+ 'pagenum' => 1,
+ 'appversioncode' => 44000,
+ ]);
+
+ $this->assert_payload_shape($result);
+ }
+
+ /**
+ * The 'review' action for a submitted response returns the expected payload shape.
+ */
+ public function test_mobile_view_activity_review_returns_payload_shape(): void {
+ $this->resetAfterTest();
+ $this->setAdminUser();
+ $questionnaire = $this->build_questionnaire();
+
+ $student = $this->getDataGenerator()->create_user();
+ $this->getDataGenerator()->enrol_user($student->id, $questionnaire->course()->id, 'student');
+ $submission = $this->getDataGenerator()->get_plugin_generator('mod_questionnaire')->generate_response(
+ $questionnaire,
+ $questionnaire->questions(),
+ (int) $student->id,
+ true
+ );
+ $this->setUser($student);
+
+ $result = mobile::mobile_view_activity([
+ 'cmid' => $questionnaire->coursemodule()->id,
+ 'action' => 'review',
+ 'submissionid' => $submission['id'],
+ 'appversioncode' => 44000,
+ ]);
+
+ $this->assert_payload_shape($result);
+ }
+
+ /**
+ * An unknown action still returns the invariant payload shape (falls through the switch).
+ */
+ public function test_mobile_view_activity_unknown_action_returns_payload_shape(): void {
+ $this->resetAfterTest();
+ $this->setAdminUser();
+ $questionnaire = $this->build_questionnaire();
+
+ $result = mobile::mobile_view_activity([
+ 'cmid' => $questionnaire->coursemodule()->id,
+ 'action' => 'no-such-action',
+ 'appversioncode' => 44000,
+ ]);
+
+ $this->assert_payload_shape($result);
+ }
+}
diff --git a/tests/output/tabs_test.php b/tests/output/tabs_test.php
new file mode 100644
index 000000000..23808f608
--- /dev/null
+++ b/tests/output/tabs_test.php
@@ -0,0 +1,324 @@
+.
+
+/**
+ * Unit tests for mod_questionnaire\output\tabs.
+ *
+ * @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;
+
+/**
+ * Unit tests for mod_questionnaire\output\tabs.
+ *
+ * @group mod_questionnaire
+ * @covers \mod_questionnaire\output\tabs
+ */
+final class tabs_test extends \advanced_testcase {
+ /**
+ * Build a course + questionnaire with one yes/no question.
+ *
+ * @return questionnaire
+ */
+ private function build_questionnaire(): questionnaire {
+ $generator = $this->getDataGenerator();
+ $course = $generator->create_course();
+ return $generator->get_plugin_generator('mod_questionnaire')->create_test_questionnaire(
+ $course,
+ QUESYESNO,
+ ['content' => 'Yes or no?'],
+ []
+ );
+ }
+
+ /**
+ * Build a course + questionnaire with NO questions attached.
+ *
+ * @return questionnaire
+ */
+ private function build_questionnaire_no_questions(): questionnaire {
+ $generator = $this->getDataGenerator();
+ $course = $generator->create_course();
+ return $generator->get_plugin_generator('mod_questionnaire')->create_test_questionnaire($course);
+ }
+
+ /**
+ * Build a course + questionnaire configured for unlimited-per-user responses.
+ *
+ * @param int $respview Value for questionnaire->respview.
+ * @return array{0: questionnaire, 1: \stdClass} [questionnaire, course]
+ */
+ private function build_questionnaire_with_settings(int $respview = 0): array {
+ $generator = $this->getDataGenerator();
+ $course = $generator->create_course();
+ $plugingen = $generator->get_plugin_generator('mod_questionnaire');
+ $questionnaire = $plugingen->create_instance([
+ 'course' => $course->id,
+ 'qtype' => 5,
+ 'respview' => $respview,
+ ]);
+ $plugingen->create_question(
+ $questionnaire,
+ ['typeid' => QUESYESNO, 'surveyid' => $questionnaire->surveyid(), 'name' => 'Q1', 'content' => 'Yes or no?']
+ );
+ return [\mod_questionnaire\questionnaire::from_instanceid($questionnaire->id()), $course];
+ }
+
+ /**
+ * Seed a complete student response so the staff "All responses" tab path is reachable.
+ *
+ * @param questionnaire $questionnaire
+ * @return void
+ */
+ private function seed_complete_response(questionnaire $questionnaire): void {
+ $generator = $this->getDataGenerator();
+ $student = $generator->create_user();
+ $generator->enrol_user($student->id, $questionnaire->course()->id, 'student');
+ $generator->get_plugin_generator('mod_questionnaire')->generate_response(
+ $questionnaire,
+ $questionnaire->questions(),
+ (int) $student->id,
+ true
+ );
+ $this->setAdminUser();
+ }
+
+ /**
+ * Render the tabs into a fresh feedbackpage and return its tabsarea HTML.
+ *
+ * @param questionnaire $questionnaire
+ * @param string $currenttab
+ * @param int|null $currentgroupid
+ * @param int|null $rid
+ * @return string
+ */
+ private function render_tabsarea(
+ questionnaire $questionnaire,
+ string $currenttab,
+ ?int $currentgroupid = null,
+ ?int $rid = null,
+ ): string {
+ global $PAGE;
+ $page = new feedbackpage();
+ (new tabs($questionnaire, $currenttab, $currentgroupid, $rid))->render($page);
+ $data = $page->export_for_template($PAGE->get_renderer('mod_questionnaire'));
+ return (string) ($data->tabsarea ?? '');
+ }
+
+ /**
+ * Owner with full caps sees the settings + questions + feedback + preview row.
+ * Uses an inactive currenttab so every management tab renders with a clickable href.
+ */
+ public function test_owner_admin_sees_management_tabs(): void {
+ $this->resetAfterTest();
+ $this->setAdminUser();
+ $questionnaire = $this->build_questionnaire();
+
+ $html = $this->render_tabsarea($questionnaire, 'inactive-tab');
+
+ $this->assertStringContainsString('qsettings.php', $html);
+ $this->assertStringContainsString('questions.php', $html);
+ $this->assertStringContainsString('feedback.php', $html);
+ $this->assertStringContainsString('preview.php', $html);
+ }
+
+ /**
+ * On the 'vall' tab with a complete response present, the all-responses sub-row appears.
+ * The vrespsummary sub-tab is clickable on this currenttab and includes byresponse=1.
+ */
+ public function test_vall_subrow_visible_when_responses_present(): void {
+ $this->resetAfterTest();
+ $this->setAdminUser();
+ $questionnaire = $this->build_questionnaire();
+ $this->seed_complete_response($questionnaire);
+
+ $html = $this->render_tabsarea($questionnaire, 'vall');
+
+ // The vall sub-row introduces the "View by response" sub-tab (vrespsummary).
+ $this->assertStringContainsString('byresponse=1', $html);
+ $this->assertStringContainsString('action=vresp', $html);
+ }
+
+ /**
+ * On the 'individualresp' tab the deleteresp sub-tab becomes a clickable link
+ * carrying the supplied rid.
+ */
+ public function test_deleteresp_tab_includes_rid(): void {
+ $this->resetAfterTest();
+ $this->setAdminUser();
+ $questionnaire = $this->build_questionnaire();
+ $this->seed_complete_response($questionnaire);
+
+ $html = $this->render_tabsarea($questionnaire, 'individualresp', 0, 4242);
+
+ $this->assertStringContainsString('rid=4242', $html);
+ }
+
+ /**
+ * The currentgroupid parameter feeds into the All-responses sub-row href group= param.
+ */
+ public function test_currentgroupid_threads_into_subrow_links(): void {
+ $this->resetAfterTest();
+ $this->setAdminUser();
+ $questionnaire = $this->build_questionnaire();
+ $this->seed_complete_response($questionnaire);
+
+ $html = $this->render_tabsarea($questionnaire, 'valldefault', 77);
+
+ $this->assertStringContainsString('group=77', $html);
+ }
+
+ /**
+ * Unknown tab name still renders the management row without errors.
+ */
+ public function test_unknown_tab_renders_main_row_only(): void {
+ $this->resetAfterTest();
+ $this->setAdminUser();
+ $questionnaire = $this->build_questionnaire();
+
+ $html = $this->render_tabsarea($questionnaire, 'no-such-tab');
+
+ // No sub-row indicators (order_default appears only on the vall sub-row).
+ $this->assertStringNotContainsString('order_default', $html);
+ // Main row still renders something for settings.
+ $this->assertStringContainsString('qsettings.php', $html);
+ }
+
+ /**
+ * Student without manage caps does NOT see the staff management row.
+ */
+ public function test_student_without_responses_sees_no_tabsarea(): void {
+ $this->resetAfterTest();
+ $questionnaire = $this->build_questionnaire();
+
+ $student = $this->getDataGenerator()->create_user();
+ $this->getDataGenerator()->enrol_user($student->id, $questionnaire->course()->id, 'student');
+ $this->setUser($student);
+
+ $html = $this->render_tabsarea($questionnaire, 'view');
+
+ // No row to print (count(row) <= 1 short-circuits).
+ $this->assertSame('', $html);
+ }
+
+ /**
+ * Preview tab does not appear when the survey has zero questions.
+ */
+ public function test_preview_tab_hidden_when_no_questions(): void {
+ $this->resetAfterTest();
+ $this->setAdminUser();
+ $questionnaire = $this->build_questionnaire_no_questions();
+
+ $html = $this->render_tabsarea($questionnaire, 'inactive-tab');
+
+ // Management row still shows settings, but preview.php is skipped when there are no questions.
+ $this->assertStringContainsString('qsettings.php', $html);
+ $this->assertStringNotContainsString('preview.php', $html);
+ }
+
+ /**
+ * Admin on the management row sees the non-respondents tab.
+ */
+ public function test_nonrespondents_tab_present_for_admin(): void {
+ $this->resetAfterTest();
+ $this->setAdminUser();
+ $questionnaire = $this->build_questionnaire();
+
+ $html = $this->render_tabsarea($questionnaire, 'inactive-tab');
+
+ $this->assertStringContainsString('show_nonrespondents.php', $html);
+ }
+
+ /**
+ * On a vall-sort tab the row3 sub-row emits order_default / order_ascending / order_descending.
+ */
+ public function test_vall_sort_subrow_emits_order_tabs(): void {
+ $this->resetAfterTest();
+ $this->setAdminUser();
+ $questionnaire = $this->build_questionnaire();
+ $this->seed_complete_response($questionnaire);
+
+ $html = $this->render_tabsarea($questionnaire, 'valldefault');
+
+ // Order-tab labels only appear from the row3 build.
+ $this->assertStringContainsString(get_string('order_default', 'questionnaire'), $html);
+ $this->assertStringContainsString(get_string('order_ascending', 'questionnaire'), $html);
+ $this->assertStringContainsString(get_string('order_descending', 'questionnaire'), $html);
+ // Download-CSV and delete-all variants of the sort-subrow.
+ $this->assertStringContainsString('action=dwnpg', $html);
+ $this->assertStringContainsString('action=delallresp', $html);
+ }
+
+ /**
+ * Student with 2+ own responses on a myreport sub-tab sees the myreport sub-row.
+ */
+ public function test_myreport_subrow_visible_for_student_with_multiple_responses(): void {
+ $this->resetAfterTest();
+ [$questionnaire, $course] = $this->build_questionnaire_with_settings();
+
+ $student = $this->getDataGenerator()->create_user();
+ $this->getDataGenerator()->enrol_user($student->id, $course->id, 'student');
+ $plugingen = $this->getDataGenerator()->get_plugin_generator('mod_questionnaire');
+ // Two completed responses for the same student — the sub-row only renders when usernumresp > 1.
+ $plugingen->generate_response($questionnaire, $questionnaire->questions(), (int) $student->id, true);
+ $plugingen->generate_response($questionnaire, $questionnaire->questions(), (int) $student->id, true);
+ $this->setUser($student);
+
+ $html = $this->render_tabsarea($questionnaire, 'myvall');
+
+ // Sub-row labels: summary / view-by-response / my-responses.
+ $this->assertStringContainsString(get_string('summary', 'questionnaire'), $html);
+ $this->assertStringContainsString(get_string('viewindividualresponse', 'questionnaire'), $html);
+ $this->assertStringContainsString(get_string('myresponses', 'questionnaire'), $html);
+ // Sub-row hrefs point at myreport.php (not report.php).
+ $this->assertStringContainsString('myreport.php', $html);
+ $this->assertStringContainsString('action=summary', $html);
+ }
+
+ /**
+ * Student with responses on a restricted questionnaire (RESPVIEW_ALWAYS, no anytime cap)
+ * hits the restricted allreport branch — distinguished by the sid= querystring arg.
+ */
+ public function test_restricted_allreport_branch_emits_sid_arg(): void {
+ $this->resetAfterTest();
+ [$questionnaire, $course] = $this->build_questionnaire_with_settings(
+ \mod_questionnaire\questionnaire::RESPVIEW_ALWAYS
+ );
+
+ $student = $this->getDataGenerator()->create_user();
+ $this->getDataGenerator()->enrol_user($student->id, $course->id, 'student');
+ $this->getDataGenerator()->get_plugin_generator('mod_questionnaire')->generate_response(
+ $questionnaire,
+ $questionnaire->questions(),
+ (int) $student->id,
+ true
+ );
+ $this->setUser($student);
+
+ $html = $this->render_tabsarea($questionnaire, 'valldefault');
+
+ // The restricted branch is the only path that includes sid= in the allreport link.
+ $this->assertStringContainsString('sid=' . $questionnaire->surveyid(), $html);
+ // And the order sub-tabs still appear (restricted branch builds a row3).
+ $this->assertStringContainsString(get_string('order_default', 'questionnaire'), $html);
+ }
+}
diff --git a/tests/privacy_provider_test.php b/tests/privacy_provider_test.php
index b4aa58840..21da4e4ae 100644
--- a/tests/privacy_provider_test.php
+++ b/tests/privacy_provider_test.php
@@ -61,7 +61,7 @@ public function test_get_contexts_for_userid(): void {
$user = $DB->get_record('user', ['firstname' => 'Testy']);
$questionnaires = $qdg->questionnaires();
$questionnaire = current($questionnaires);
- [$course, $cm] = get_course_and_cm_from_instance($questionnaire->id, 'questionnaire', $questionnaire->course);
+ [$course, $cm] = get_course_and_cm_from_instance($questionnaire->id(), 'questionnaire', $questionnaire->courseid());
$contextlist = provider::get_contexts_for_userid($user->id);
// Check that we only get back one context.
@@ -87,7 +87,7 @@ public function test_get_users_in_context(): void {
$user = $DB->get_record('user', ['firstname' => 'Testy']);
$questionnaires = $qdg->questionnaires();
$questionnaire = current($questionnaires);
- [$course, $cm] = get_course_and_cm_from_instance($questionnaire->id, 'questionnaire', $questionnaire->course);
+ [$course, $cm] = get_course_and_cm_from_instance($questionnaire->id(), 'questionnaire', $questionnaire->courseid());
$cmcontext = \context_module::instance($cm->id);
$userlist = new \core_privacy\local\request\userlist($cmcontext, 'mod_questionnaire');
@@ -120,7 +120,7 @@ public function test_export_user_data(): void {
$user = $DB->get_record('user', ['firstname' => 'Testy']);
$questionnaires = $qdg->questionnaires();
$questionnaire = current($questionnaires);
- [$course, $cm] = get_course_and_cm_from_instance($questionnaire->id, 'questionnaire', $questionnaire->course);
+ [$course, $cm] = get_course_and_cm_from_instance($questionnaire->id(), 'questionnaire', $questionnaire->courseid());
$cmcontext = \context_module::instance($cm->id);
$writer = \core_privacy\local\request\writer::with_context($cmcontext);
@@ -130,8 +130,8 @@ public function test_export_user_data(): void {
provider::export_user_data($approvedlist);
$data = $writer->get_data([]);
- $this->assertStringContainsString($questionnaire->name, strip_tags($data->name));
- $this->assertEquals($questionnaire->intro, strip_tags($data->intro));
+ $this->assertStringContainsString($questionnaire->name(), strip_tags($data->name));
+ $this->assertEquals($questionnaire->intro(), strip_tags($data->intro));
$this->assertNotEmpty($data->responses[0]['questions']);
$this->assertEquals('1. Text Box 1000', $data->responses[0]['questions'][1]->questionname);
$this->assertEquals('Test answer', $data->responses[0]['questions'][1]->answers[0]);
@@ -157,7 +157,7 @@ public function test_delete_data_for_all_users_in_context(): void {
$user = $DB->get_record('user', ['username' => 'username1']);
$questionnaires = $qdg->questionnaires();
$questionnaire = current($questionnaires);
- [$course, $cm] = get_course_and_cm_from_instance($questionnaire->id, 'questionnaire', $questionnaire->course);
+ [$course, $cm] = get_course_and_cm_from_instance($questionnaire->id(), 'questionnaire', $questionnaire->courseid());
$cmcontext = \context_module::instance($cm->id);
// Get all accounts. There should be two.
@@ -183,7 +183,7 @@ public function test_delete_data_for_user(): void {
$user = $DB->get_record('user', ['username' => 'username1']);
$questionnaires = $qdg->questionnaires();
$questionnaire = current($questionnaires);
- [$course, $cm] = get_course_and_cm_from_instance($questionnaire->id, 'questionnaire', $questionnaire->course);
+ [$course, $cm] = get_course_and_cm_from_instance($questionnaire->id(), 'questionnaire', $questionnaire->courseid());
$cmcontext = \context_module::instance($cm->id);
// Get all accounts. There should be two.
@@ -215,7 +215,7 @@ public function test_delete_data_for_users(): void {
$user3 = $DB->get_record('user', ['username' => 'username3']);
$questionnaires = $qdg->questionnaires();
$questionnaire = current($questionnaires);
- [$course, $cm] = get_course_and_cm_from_instance($questionnaire->id, 'questionnaire', $questionnaire->course);
+ [$course, $cm] = get_course_and_cm_from_instance($questionnaire->id(), 'questionnaire', $questionnaire->courseid());
$cmcontext = \context_module::instance($cm->id);
$approveduserlist = new \core_privacy\local\request\approved_userlist($cmcontext, 'questionnaire', [$user->id, $user3->id]);
diff --git a/tests/question_navigator_test.php b/tests/question_navigator_test.php
new file mode 100644
index 000000000..a7c17ee35
--- /dev/null
+++ b/tests/question_navigator_test.php
@@ -0,0 +1,350 @@
+.
+
+/**
+ * Unit tests for mod_questionnaire\local\question_navigator.
+ *
+ * All tests use in-memory stubs (no DB) via survey_testable and anonymous
+ * question stubs — the same pattern used in questionnaire_test.php.
+ *
+ * @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;
+
+use mod_questionnaire\local\db\survey_record;
+use mod_questionnaire\local\question_navigator;
+use mod_questionnaire\local\survey\survey_testable;
+
+defined('MOODLE_INTERNAL') || die();
+
+global $CFG;
+require_once($CFG->dirroot . '/mod/questionnaire/tests/local/survey/survey_testable.php');
+
+/**
+ * Unit tests for question_navigator.
+ *
+ * @group mod_questionnaire
+ * @covers \mod_questionnaire\local\question_navigator
+ */
+final class question_navigator_test extends \advanced_testcase {
+ // Helpers.
+
+ /**
+ * Build an in-memory survey_record with sensible defaults.
+ *
+ * @param array $fields
+ * @return survey_record
+ */
+ private function make_survey_record(array $fields = []): survey_record {
+ return new survey_record(0, (object)array_merge([
+ 'id' => 1,
+ 'name' => 'Test survey',
+ 'courseid' => 1,
+ 'realm' => 'private',
+ 'status' => 0,
+ 'title' => '',
+ 'email' => null,
+ 'subtitle' => null,
+ 'info' => null,
+ 'theme' => null,
+ 'thankspage' => null,
+ 'thankhead' => null,
+ 'thankbody' => null,
+ 'feedbacksections' => 0,
+ 'feedbacknotes' => null,
+ 'feedbackscores' => 0,
+ 'charttype' => null,
+ ], $fields));
+ }
+
+ /**
+ * Build a survey_testable with injected questions.
+ *
+ * @param array $questions Keyed by question id.
+ * @param array $questionsbysec Keyed by 1-based section number.
+ * @return survey_testable
+ */
+ private function make_survey(array $questions = [], array $questionsbysec = []): survey_testable {
+ $survey = new survey_testable($this->make_survey_record());
+ $survey->set_questions($questions);
+ $survey->set_questions_by_sec($questionsbysec);
+ return $survey;
+ }
+
+ /**
+ * Build a question_navigator from a survey and a skip-logic flag.
+ *
+ * @param survey_testable $survey
+ * @param bool $skiplogicenabled
+ * @return question_navigator
+ */
+ private function make_navigator(survey_testable $survey, bool $skiplogicenabled = false): question_navigator {
+ return new question_navigator($survey, $skiplogicenabled);
+ }
+
+ /**
+ * Build an anonymous question stub with the given id and dependency list.
+ *
+ * The stub satisfies the interface expected by question_navigator:
+ * has_dependencies(), id(), dependency_fulfilled().
+ *
+ * @param int $id
+ * @param array $dependencies
+ * @return object
+ */
+ private function make_stub_question(int $id, array $dependencies): object {
+ return new class ($id, $dependencies) {
+ /** @var int */
+ public int $id;
+ /** @var array */
+ public array $dependencies;
+ /** @var int */
+ public int $position = 0;
+ /** @var string */
+ public string $name = '';
+ /** @var string */
+ public string $content = '';
+ /** @var int */
+ public int $typeid = 0;
+
+ /**
+ * Constructor.
+ * @param int $id
+ * @param array $dependencies
+ */
+ public function __construct(int $id, array $dependencies) {
+ $this->id = $id;
+ $this->dependencies = $dependencies;
+ }
+
+ /**
+ * True if dependencies are non-empty.
+ * @return bool
+ */
+ public function has_dependencies(): bool {
+ return !empty($this->dependencies);
+ }
+
+ /**
+ * Return the question id.
+ * @return int
+ */
+ public function id(): int {
+ return $this->id;
+ }
+
+ /**
+ * Always fulfilled when no dependencies are set (used in navigation tests).
+ * @param mixed $rid
+ * @param mixed $questions
+ * @return bool
+ */
+ public function dependency_fulfilled($rid, $questions): bool {
+ return !$this->has_dependencies();
+ }
+ };
+ }
+
+ // Tests for has_dependencies().
+
+ /**
+ * Asserts has_dependencies() returns false when skip-logic is disabled.
+ *
+ * @covers \mod_questionnaire\local\question_navigator::has_dependencies
+ */
+ public function test_has_dependencies_false_when_skiplogic_disabled(): void {
+ $dep = (object)['dependquestionid' => 2, 'dependchoiceid' => 1, 'dependlogic' => 1, 'dependandor' => 'and'];
+ $survey = $this->make_survey([
+ 1 => $this->make_stub_question(1, []),
+ 2 => $this->make_stub_question(2, [99 => $dep]),
+ ]);
+ $nav = $this->make_navigator($survey, false);
+ $this->assertFalse($nav->has_dependencies());
+ }
+
+ /**
+ * Asserts has_dependencies() returns false when skip-logic is on but no question has deps.
+ *
+ * @covers \mod_questionnaire\local\question_navigator::has_dependencies
+ */
+ public function test_has_dependencies_false_when_no_question_has_deps(): void {
+ $survey = $this->make_survey([
+ 1 => $this->make_stub_question(1, []),
+ 2 => $this->make_stub_question(2, []),
+ ]);
+ $nav = $this->make_navigator($survey, true);
+ $this->assertFalse($nav->has_dependencies());
+ }
+
+ /**
+ * Asserts has_dependencies() returns true when skip-logic is on and a question has a dep.
+ *
+ * @covers \mod_questionnaire\local\question_navigator::has_dependencies
+ */
+ public function test_has_dependencies_true_when_question_has_dep(): void {
+ $dep = (object)['dependquestionid' => 1, 'dependchoiceid' => 1, 'dependlogic' => 1, 'dependandor' => 'and'];
+ $survey = $this->make_survey([
+ 1 => $this->make_stub_question(1, []),
+ 2 => $this->make_stub_question(2, [99 => $dep]),
+ ]);
+ $nav = $this->make_navigator($survey, true);
+ $this->assertTrue($nav->has_dependencies());
+ }
+
+ // Tests for get_dependants().
+
+ /**
+ * Asserts get_dependants() returns an empty array when no questions depend on the given question.
+ *
+ * @covers \mod_questionnaire\local\question_navigator::get_dependants
+ */
+ public function test_get_dependants_returns_empty_when_none(): void {
+ $survey = $this->make_survey([
+ 1 => $this->make_stub_question(1, []),
+ 2 => $this->make_stub_question(2, []),
+ ]);
+ $nav = $this->make_navigator($survey);
+ $this->assertSame([], $nav->get_dependants(1));
+ }
+
+ /**
+ * Asserts get_dependants() returns IDs of questions that depend on the given question.
+ *
+ * @covers \mod_questionnaire\local\question_navigator::get_dependants
+ */
+ public function test_get_dependants_returns_dependent_ids(): void {
+ $dep = (object)['dependquestionid' => 1, 'dependchoiceid' => 1, 'dependlogic' => 1, 'dependandor' => 'and'];
+ $survey = $this->make_survey([
+ 1 => $this->make_stub_question(1, []),
+ 2 => $this->make_stub_question(2, [10 => $dep]),
+ 3 => $this->make_stub_question(3, []),
+ ]);
+ $nav = $this->make_navigator($survey);
+ $this->assertSame([2], $nav->get_dependants(1));
+ }
+
+ /**
+ * Asserts get_dependants() returns multiple IDs when several questions depend on the same parent.
+ *
+ * @covers \mod_questionnaire\local\question_navigator::get_dependants
+ */
+ public function test_get_dependants_returns_multiple_dependants(): void {
+ $dep1 = (object)['dependquestionid' => 1, 'dependchoiceid' => 1, 'dependlogic' => 1, 'dependandor' => 'and'];
+ $dep2 = (object)['dependquestionid' => 1, 'dependchoiceid' => 2, 'dependlogic' => 1, 'dependandor' => 'and'];
+ $survey = $this->make_survey([
+ 1 => $this->make_stub_question(1, []),
+ 2 => $this->make_stub_question(2, [10 => $dep1]),
+ 3 => $this->make_stub_question(3, [11 => $dep2]),
+ ]);
+ $nav = $this->make_navigator($survey);
+ $result = $nav->get_dependants(1);
+ sort($result);
+ $this->assertSame([2, 3], $result);
+ }
+
+ // Tests for get_dependants_and_choices().
+
+ /**
+ * Asserts get_dependants_and_choices() returns an empty array when no dependencies exist.
+ *
+ * @covers \mod_questionnaire\local\question_navigator::get_dependants_and_choices
+ */
+ public function test_get_dependants_and_choices_empty_when_none(): void {
+ $survey = $this->make_survey([
+ 1 => $this->make_stub_question(1, []),
+ 2 => $this->make_stub_question(2, []),
+ ]);
+ $nav = $this->make_navigator($survey);
+ $this->assertSame([], $nav->get_dependants_and_choices());
+ }
+
+ /**
+ * Asserts get_dependants_and_choices() returns the correct parent→child map.
+ *
+ * @covers \mod_questionnaire\local\question_navigator::get_dependants_and_choices
+ */
+ public function test_get_dependants_and_choices_returns_map(): void {
+ $dep = (object)['dependquestionid' => 1, 'dependchoiceid' => 5, 'dependlogic' => 1, 'dependandor' => 'and'];
+ $survey = $this->make_survey([
+ 1 => $this->make_stub_question(1, []),
+ 2 => $this->make_stub_question(2, [10 => $dep]),
+ ]);
+ $nav = $this->make_navigator($survey);
+ $result = $nav->get_dependants_and_choices();
+ $this->assertArrayHasKey(1, $result);
+ $this->assertArrayHasKey(2, $result[1]);
+ $this->assertEquals(5, $result[1][2][0]->choiceid);
+ $this->assertEquals(1, $result[1][2][0]->logic);
+ $this->assertEquals('and', $result[1][2][0]->andor);
+ }
+
+ // Tests for the eligible_questions_on_page, next_page and prev_page navigation methods.
+
+ /**
+ * Asserts eligible_questions_on_page() returns true for a question with no dependencies.
+ *
+ * @covers \mod_questionnaire\local\question_navigator::eligible_questions_on_page
+ */
+ public function test_eligible_questions_on_page_with_no_deps(): void {
+ $stub = $this->make_stub_question(1, []);
+ $survey = $this->make_survey([1 => $stub], [1 => [$stub]]);
+ $nav = $this->make_navigator($survey, false);
+ $this->assertTrue($nav->eligible_questions_on_page(1, 0));
+ }
+
+ /**
+ * Asserts next_page() increments section when there are no dependencies.
+ *
+ * @covers \mod_questionnaire\local\question_navigator::next_page
+ */
+ public function test_next_page_no_dependencies(): void {
+ $stub1 = $this->make_stub_question(1, []);
+ $stub2 = $this->make_stub_question(2, []);
+ $stub3 = $this->make_stub_question(3, []);
+ $survey = $this->make_survey(
+ [1 => $stub1, 2 => $stub2, 3 => $stub3],
+ [1 => [$stub1], 2 => [$stub2], 3 => [$stub3]]
+ );
+ $nav = $this->make_navigator($survey, false);
+ $this->assertEquals(2, $nav->next_page(1, 0));
+ $this->assertEquals(3, $nav->next_page(2, 0));
+ // Past the last section — returns the (invalid) incremented number, not false,
+ // because the skip-loop only runs when has_dependencies() is true.
+ $this->assertEquals(4, $nav->next_page(3, 0));
+ }
+
+ /**
+ * Asserts prev_page() decrements section when there are no dependencies.
+ *
+ * @covers \mod_questionnaire\local\question_navigator::prev_page
+ */
+ public function test_prev_page_no_dependencies(): void {
+ $stub1 = $this->make_stub_question(1, []);
+ $stub2 = $this->make_stub_question(2, []);
+ $survey = $this->make_survey(
+ [1 => $stub1, 2 => $stub2],
+ [1 => [$stub1], 2 => [$stub2]]
+ );
+ $nav = $this->make_navigator($survey, false);
+ $this->assertEquals(1, $nav->prev_page(2, 0));
+ // Section 1 → decrements to 0 → returns false.
+ $this->assertFalse($nav->prev_page(1, 0));
+ }
+}
diff --git a/tests/question_type_test.php b/tests/question_type_test.php
new file mode 100644
index 000000000..926664e20
--- /dev/null
+++ b/tests/question_type_test.php
@@ -0,0 +1,205 @@
+.
+
+/**
+ * Unit tests for mod_questionnaire\local\question_type.
+ *
+ * @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;
+
+use mod_questionnaire\local\question_type;
+
+defined('MOODLE_INTERNAL') || die();
+
+global $CFG;
+
+/**
+ * Unit tests for mod_questionnaire\local\question_type.
+ *
+ * @group mod_questionnaire
+ * @covers \mod_questionnaire\local\question_type
+ */
+final class question_type_test extends \advanced_testcase {
+ // Tests for qtypename().
+
+ /**
+ * Asserts qtypename() returns the correct short name for the yes/no type.
+ *
+ * @covers \mod_questionnaire\local\question_type::qtypename
+ */
+ public function test_qtypename_returns_yesno_for_type_1(): void {
+ $this->assertEquals('yesno', question_type::qtypename(question_type::QUESYESNO));
+ }
+
+ /**
+ * Asserts qtypename() returns the correct short name for the essay type.
+ *
+ * @covers \mod_questionnaire\local\question_type::qtypename
+ */
+ public function test_qtypename_returns_essay_for_essay_type(): void {
+ $this->assertEquals('essay', question_type::qtypename(question_type::QUESESSAY));
+ }
+
+ /**
+ * Asserts qtypename() returns an empty string for an unknown type id.
+ *
+ * @covers \mod_questionnaire\local\question_type::qtypename
+ */
+ public function test_qtypename_returns_empty_string_for_unknown_type(): void {
+ $this->assertSame('', question_type::qtypename(999));
+ }
+
+ /**
+ * Asserts qtypename() returns an empty string for QUESCHOOSE which is a UI placeholder.
+ *
+ * @covers \mod_questionnaire\local\question_type::qtypename
+ */
+ public function test_qtypename_returns_empty_string_for_choose_type(): void {
+ $this->assertSame('', question_type::qtypename(question_type::QUESCHOOSE));
+ }
+
+ // Tests for qtypenames().
+
+ /**
+ * Asserts qtypenames() returns an array.
+ *
+ * @covers \mod_questionnaire\local\question_type::qtypenames
+ */
+ public function test_qtypenames_returns_array(): void {
+ $this->assertIsArray(question_type::qtypenames());
+ }
+
+ /**
+ * Asserts qtypenames() contains entries for all standard question types.
+ *
+ * @covers \mod_questionnaire\local\question_type::qtypenames
+ */
+ public function test_qtypenames_contains_all_standard_types(): void {
+ $names = question_type::qtypenames();
+ $this->assertArrayHasKey(question_type::QUESYESNO, $names);
+ $this->assertArrayHasKey(question_type::QUESTEXT, $names);
+ $this->assertArrayHasKey(question_type::QUESESSAY, $names);
+ $this->assertArrayHasKey(question_type::QUESRADIO, $names);
+ $this->assertArrayHasKey(question_type::QUESCHECK, $names);
+ $this->assertArrayHasKey(question_type::QUESDROP, $names);
+ $this->assertArrayHasKey(question_type::QUESRATE, $names);
+ $this->assertArrayHasKey(question_type::QUESDATE, $names);
+ $this->assertArrayHasKey(question_type::QUESNUMERIC, $names);
+ $this->assertArrayHasKey(question_type::QUESSLIDER, $names);
+ $this->assertArrayHasKey(question_type::QUESFILE, $names);
+ $this->assertArrayHasKey(question_type::QUESPAGEBREAK, $names);
+ $this->assertArrayHasKey(question_type::QUESSECTIONTEXT, $names);
+ }
+
+ /**
+ * Asserts qtypenames() does not include QUESCHOOSE which is a UI placeholder with no stored type.
+ *
+ * @covers \mod_questionnaire\local\question_type::qtypenames
+ */
+ public function test_qtypenames_does_not_include_choose_type(): void {
+ $this->assertArrayNotHasKey(question_type::QUESCHOOSE, question_type::qtypenames());
+ }
+
+ // Tests for display_name().
+
+ /**
+ * Asserts display_name() returns a non-empty string for every known question type.
+ *
+ * @covers \mod_questionnaire\local\question_type::display_name
+ */
+ public function test_display_name_returns_non_empty_string_for_each_known_type(): void {
+ $this->resetAfterTest();
+ $types = [
+ question_type::QUESYESNO,
+ question_type::QUESTEXT,
+ question_type::QUESESSAY,
+ question_type::QUESRADIO,
+ question_type::QUESCHECK,
+ question_type::QUESDROP,
+ question_type::QUESRATE,
+ question_type::QUESDATE,
+ question_type::QUESNUMERIC,
+ question_type::QUESSLIDER,
+ question_type::QUESFILE,
+ question_type::QUESSECTIONTEXT,
+ question_type::QUESPAGEBREAK,
+ ];
+ foreach ($types as $typeid) {
+ $name = question_type::display_name($typeid);
+ $this->assertNotEmpty($name, "display_name($typeid) should not be empty");
+ $this->assertIsString($name);
+ }
+ }
+
+ /**
+ * Asserts display_name() returns an empty string for an unknown type id.
+ *
+ * @covers \mod_questionnaire\local\question_type::display_name
+ */
+ public function test_display_name_returns_empty_string_for_unknown_type(): void {
+ $this->assertSame('', question_type::display_name(999));
+ }
+
+ /**
+ * Asserts display_name() returns an empty string for QUESCHOOSE which has no display name.
+ *
+ * @covers \mod_questionnaire\local\question_type::display_name
+ */
+ public function test_display_name_returns_empty_string_for_choose_type(): void {
+ $this->assertSame('', question_type::display_name(question_type::QUESCHOOSE));
+ }
+
+ // Tests for from_typeid().
+
+ /**
+ * Asserts from_typeid() returns a question_type instance for a known type.
+ *
+ * @covers \mod_questionnaire\local\question_type::from_typeid
+ */
+ public function test_from_typeid_returns_question_type_for_yesno(): void {
+ $this->resetAfterTest();
+ $qt = question_type::from_typeid(question_type::QUESYESNO);
+ $this->assertNotNull($qt);
+ $this->assertInstanceOf(question_type::class, $qt);
+ }
+
+ /**
+ * Asserts from_typeid() returns null when the type id does not exist in the database.
+ *
+ * @covers \mod_questionnaire\local\question_type::from_typeid
+ */
+ public function test_from_typeid_returns_null_for_invalid_typeid(): void {
+ $this->resetAfterTest();
+ $this->assertNull(question_type::from_typeid(999));
+ }
+
+ /**
+ * Asserts from_typeid() returns the same instance on repeated calls due to per-request caching.
+ *
+ * @covers \mod_questionnaire\local\question_type::from_typeid
+ */
+ public function test_from_typeid_returns_same_instance_on_repeat_call(): void {
+ $this->resetAfterTest();
+ $first = question_type::from_typeid(question_type::QUESTEXT);
+ $second = question_type::from_typeid(question_type::QUESTEXT);
+ $this->assertSame($first, $second);
+ }
+}
diff --git a/tests/questionnaire_responses_test.php b/tests/questionnaire_responses_test.php
new file mode 100644
index 000000000..31d971aac
--- /dev/null
+++ b/tests/questionnaire_responses_test.php
@@ -0,0 +1,580 @@
+.
+
+/**
+ * Unit tests for mod_questionnaire\local\response\questionnaire_responses static methods.
+ *
+ * @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;
+
+use mod_questionnaire\local\question_type;
+use mod_questionnaire\local\response\questionnaire_responses;
+
+defined('MOODLE_INTERNAL') || die();
+
+global $CFG;
+
+/**
+ * Unit tests for mod_questionnaire\local\response\questionnaire_responses static methods.
+ *
+ * @group mod_questionnaire
+ * @covers \mod_questionnaire\local\response\questionnaire_responses
+ */
+final class questionnaire_responses_test extends \advanced_testcase {
+ // Tests for count_for_question().
+
+ /**
+ * Asserts count_for_question() returns zero for the QUESSECTIONTEXT type without any DB query.
+ *
+ * @covers \mod_questionnaire\local\response\questionnaire_responses::count_for_question
+ */
+ public function test_count_for_question_returns_zero_for_sectiontext(): void {
+ $this->assertEquals(0, questionnaire_responses::count_for_question(99, question_type::QUESSECTIONTEXT));
+ }
+
+ /**
+ * Asserts count_for_question() returns zero when no response records exist for the question.
+ *
+ * @covers \mod_questionnaire\local\response\questionnaire_responses::count_for_question
+ */
+ public function test_count_for_question_returns_zero_when_no_records(): void {
+ $this->resetAfterTest();
+ // Use a question id that has never had responses.
+ $this->assertEquals(0, questionnaire_responses::count_for_question(99999, question_type::QUESYESNO));
+ }
+
+ /**
+ * Asserts count_for_question() returns the correct count after inserting response records.
+ *
+ * @covers \mod_questionnaire\local\response\questionnaire_responses::count_for_question
+ */
+ public function test_count_for_question_returns_correct_count(): void {
+ global $DB;
+ $this->resetAfterTest();
+
+ // Insert two yes/no response records for the same question id.
+ $qid = 12301;
+ $DB->insert_record('questionnaire_response_bool', (object)['questionid' => $qid, 'responseid' => 1, 'choice' => 'y']);
+ $DB->insert_record('questionnaire_response_bool', (object)['questionid' => $qid, 'responseid' => 2, 'choice' => 'n']);
+
+ $this->assertEquals(2, questionnaire_responses::count_for_question($qid, question_type::QUESYESNO));
+ }
+
+ /**
+ * Asserts count_for_question() only counts records for the specified question id.
+ *
+ * @covers \mod_questionnaire\local\response\questionnaire_responses::count_for_question
+ */
+ public function test_count_for_question_counts_only_matching_questionid(): void {
+ global $DB;
+ $this->resetAfterTest();
+
+ $qid = 12302;
+ $otherid = 12303;
+ $DB->insert_record('questionnaire_response_bool', (object)['questionid' => $qid, 'responseid' => 1, 'choice' => 'y']);
+ $DB->insert_record('questionnaire_response_bool', (object)['questionid' => $otherid, 'responseid' => 2, 'choice' => 'n']);
+
+ $this->assertEquals(1, questionnaire_responses::count_for_question($qid, question_type::QUESYESNO));
+ }
+
+ /**
+ * Asserts count_for_question() returns zero for an unknown type id with no response table.
+ *
+ * @covers \mod_questionnaire\local\response\questionnaire_responses::count_for_question
+ */
+ public function test_count_for_question_returns_zero_for_unknown_type(): void {
+ $this->resetAfterTest();
+ $this->assertEquals(0, questionnaire_responses::count_for_question(1, 999));
+ }
+
+ // Tests for delete_old_responses().
+
+ /**
+ * Asserts delete_old_responses() deletes responses older than the questionnaire's retention period.
+ *
+ * @covers \mod_questionnaire\local\response\questionnaire_responses::delete_old_responses
+ */
+ public function test_delete_old_responses_removes_expired_responses(): void {
+ global $DB;
+ $this->resetAfterTest();
+ $this->setAdminUser();
+
+ $generator = $this->getDataGenerator();
+ $course = $generator->create_course();
+ $questionnaire = $this->getDataGenerator()
+ ->get_plugin_generator('mod_questionnaire')
+ ->create_instance(['course' => $course->id]);
+
+ // Set removeafter to 1 hour.
+ $onehoursecs = HOURSECS;
+ $DB->set_field('questionnaire', 'removeafter', $onehoursecs, ['id' => $questionnaire->id()]);
+
+ // Insert a response submitted 2 hours ago (expired).
+ $expiredid = $DB->insert_record('questionnaire_response', (object)[
+ 'questionnaireid' => $questionnaire->id(),
+ 'userid' => 2,
+ 'submitted' => time() - (2 * HOURSECS),
+ 'complete' => 'y',
+ 'grade' => 0,
+ ]);
+
+ questionnaire_responses::delete_old_responses();
+
+ $this->assertFalse($DB->record_exists('questionnaire_response', ['id' => $expiredid]));
+ }
+
+ /**
+ * Asserts delete_old_responses() leaves responses that are still within the retention period.
+ *
+ * @covers \mod_questionnaire\local\response\questionnaire_responses::delete_old_responses
+ */
+ public function test_delete_old_responses_keeps_recent_responses(): void {
+ global $DB;
+ $this->resetAfterTest();
+ $this->setAdminUser();
+
+ $generator = $this->getDataGenerator();
+ $course = $generator->create_course();
+ $questionnaire = $this->getDataGenerator()
+ ->get_plugin_generator('mod_questionnaire')
+ ->create_instance(['course' => $course->id]);
+
+ // Set removeafter to 1 day.
+ $DB->set_field('questionnaire', 'removeafter', DAYSECS, ['id' => $questionnaire->id()]);
+
+ // Insert a response submitted 1 hour ago (still within 1 day retention).
+ $recentid = $DB->insert_record('questionnaire_response', (object)[
+ 'questionnaireid' => $questionnaire->id(),
+ 'userid' => 2,
+ 'submitted' => time() - HOURSECS,
+ 'complete' => 'y',
+ 'grade' => 0,
+ ]);
+
+ questionnaire_responses::delete_old_responses();
+
+ $this->assertTrue($DB->record_exists('questionnaire_response', ['id' => $recentid]));
+ }
+
+ /**
+ * Asserts delete_old_responses() leaves responses when removeafter is zero (disabled).
+ *
+ * @covers \mod_questionnaire\local\response\questionnaire_responses::delete_old_responses
+ */
+ public function test_delete_old_responses_ignores_when_removeafter_is_zero(): void {
+ global $DB;
+ $this->resetAfterTest();
+ $this->setAdminUser();
+
+ $generator = $this->getDataGenerator();
+ $course = $generator->create_course();
+ $questionnaire = $this->getDataGenerator()
+ ->get_plugin_generator('mod_questionnaire')
+ ->create_instance(['course' => $course->id]);
+
+ // Explicitly set removeafter to 0 (disabled).
+ $DB->set_field('questionnaire', 'removeafter', 0, ['id' => $questionnaire->id()]);
+
+ // Insert a very old response.
+ $oldid = $DB->insert_record('questionnaire_response', (object)[
+ 'questionnaireid' => $questionnaire->id(),
+ 'userid' => 2,
+ 'submitted' => time() - (365 * DAYSECS),
+ 'complete' => 'y',
+ 'grade' => 0,
+ ]);
+
+ questionnaire_responses::delete_old_responses();
+
+ // With removeafter=0 the WHERE clause filters it out; the response must survive.
+ $this->assertTrue($DB->record_exists('questionnaire_response', ['id' => $oldid]));
+ }
+
+ /**
+ * Asserts delete_old_responses() also removes answer rows from all response detail tables.
+ *
+ * @covers \mod_questionnaire\local\response\questionnaire_responses::delete_old_responses
+ */
+ public function test_delete_old_responses_removes_answer_rows(): void {
+ global $DB;
+ $this->resetAfterTest();
+ $this->setAdminUser();
+
+ $generator = $this->getDataGenerator();
+ $course = $generator->create_course();
+ $questionnaire = $this->getDataGenerator()
+ ->get_plugin_generator('mod_questionnaire')
+ ->create_instance(['course' => $course->id]);
+
+ $DB->set_field('questionnaire', 'removeafter', HOURSECS, ['id' => $questionnaire->id()]);
+
+ $expiredrid = $DB->insert_record('questionnaire_response', (object)[
+ 'questionnaireid' => $questionnaire->id(),
+ 'userid' => 2,
+ 'submitted' => time() - (2 * HOURSECS),
+ 'complete' => 'y',
+ 'grade' => 0,
+ ]);
+
+ // Insert an answer row in questionnaire_response_bool referencing the expired response.
+ $DB->insert_record('questionnaire_response_bool', (object)[
+ 'questionid' => 1, 'responseid' => $expiredrid, 'choice' => 'y',
+ ]);
+
+ questionnaire_responses::delete_old_responses();
+
+ $this->assertFalse(
+ $DB->record_exists('questionnaire_response_bool', ['responseid' => $expiredrid]),
+ 'Answer rows in response_bool should be deleted with the expired response.'
+ );
+ }
+
+ // Tests for get_incomplete_users().
+
+ /**
+ * Asserts get_incomplete_users() returns false when no users are enrolled.
+ *
+ * @covers \mod_questionnaire\local\response\questionnaire_responses::get_incomplete_users
+ */
+ public function test_get_incomplete_users_returns_false_when_no_enrolled_users(): void {
+ $this->resetAfterTest();
+ $this->setAdminUser();
+
+ $course = $this->getDataGenerator()->create_course();
+ $questionnaire = $this->getDataGenerator()
+ ->get_plugin_generator('mod_questionnaire')
+ ->create_instance(['course' => $course->id]);
+
+ $cm = get_coursemodule_from_instance('questionnaire', $questionnaire->id(), $course->id, false, MUST_EXIST);
+
+ // No students enrolled; get_enrolled_users() returns empty → false.
+ $result = questionnaire_responses::get_incomplete_users($cm, $questionnaire->surveyid());
+ $this->assertFalse($result);
+ }
+
+ /**
+ * Asserts get_incomplete_users() returns enrolled users who have not completed the questionnaire.
+ *
+ * @covers \mod_questionnaire\local\response\questionnaire_responses::get_incomplete_users
+ */
+ public function test_get_incomplete_users_returns_users_without_completion(): void {
+ global $DB;
+ $this->resetAfterTest();
+ $this->setAdminUser();
+
+ $generator = $this->getDataGenerator();
+ $course = $generator->create_course();
+ $questionnaire = $generator
+ ->get_plugin_generator('mod_questionnaire')
+ ->create_instance(['course' => $course->id]);
+
+ $student1 = $generator->create_user();
+ $student2 = $generator->create_user();
+ $studentrole = $DB->get_record('role', ['shortname' => 'student'], '*', MUST_EXIST);
+ $generator->enrol_user($student1->id, $course->id, $studentrole->id);
+ $generator->enrol_user($student2->id, $course->id, $studentrole->id);
+
+ $cm = get_coursemodule_from_instance('questionnaire', $questionnaire->id(), $course->id, false, MUST_EXIST);
+ $result = questionnaire_responses::get_incomplete_users($cm, $questionnaire->surveyid());
+
+ // Normalise to integers to avoid driver-specific string/int key differences.
+ $intresult = is_array($result) ? array_map('intval', $result) : [];
+ $this->assertNotEmpty($intresult, 'Enrolled students should appear as incomplete users.');
+ $this->assertContains((int)$student1->id, $intresult);
+ $this->assertContains((int)$student2->id, $intresult);
+ }
+
+ /**
+ * Asserts get_incomplete_users() excludes users who have submitted a complete response.
+ *
+ * @covers \mod_questionnaire\local\response\questionnaire_responses::get_incomplete_users
+ */
+ public function test_get_incomplete_users_excludes_completers(): void {
+ global $DB;
+ $this->resetAfterTest();
+ $this->setAdminUser();
+
+ $generator = $this->getDataGenerator();
+ $course = $generator->create_course();
+ $questionnaire = $generator
+ ->get_plugin_generator('mod_questionnaire')
+ ->create_instance(['course' => $course->id]);
+
+ $student1 = $generator->create_user();
+ $student2 = $generator->create_user();
+ $studentrole = $DB->get_record('role', ['shortname' => 'student'], '*', MUST_EXIST);
+ $generator->enrol_user($student1->id, $course->id, $studentrole->id);
+ $generator->enrol_user($student2->id, $course->id, $studentrole->id);
+
+ // Student1 submits a complete response.
+ $DB->insert_record('questionnaire_response', (object)[
+ 'questionnaireid' => $questionnaire->id(),
+ 'userid' => $student1->id,
+ 'submitted' => time(),
+ 'complete' => 'y',
+ 'grade' => 0,
+ ]);
+
+ $cm = get_coursemodule_from_instance('questionnaire', $questionnaire->id(), $course->id, false, MUST_EXIST);
+ $result = questionnaire_responses::get_incomplete_users($cm, $questionnaire->surveyid());
+
+ $intresult = is_array($result) ? array_map('intval', $result) : [];
+ $this->assertNotContains((int)$student1->id, $intresult);
+ $this->assertContains((int)$student2->id, $intresult);
+ }
+
+ /**
+ * Asserts get_incomplete_users() supports paging via startpage and pagecount parameters.
+ *
+ * The internal early-return path (no completions yet) skips paging, so a completed
+ * response must exist before the paging slice can be exercised.
+ *
+ * @covers \mod_questionnaire\local\response\questionnaire_responses::get_incomplete_users
+ */
+ public function test_get_incomplete_users_supports_paging(): void {
+ global $DB;
+ $this->resetAfterTest();
+ $this->setAdminUser();
+
+ $generator = $this->getDataGenerator();
+ $course = $generator->create_course();
+ $questionnaire = $generator
+ ->get_plugin_generator('mod_questionnaire')
+ ->create_instance(['course' => $course->id]);
+
+ $studentrole = $DB->get_record('role', ['shortname' => 'student'], '*', MUST_EXIST);
+ $students = [];
+ for ($i = 0; $i < 5; $i++) {
+ $student = $generator->create_user();
+ $generator->enrol_user($student->id, $course->id, $studentrole->id);
+ $students[] = $student;
+ }
+
+ // One student completes — triggers the completion-check path that applies paging.
+ $DB->insert_record('questionnaire_response', (object)[
+ 'questionnaireid' => $questionnaire->id(),
+ 'userid' => $students[0]->id,
+ 'submitted' => time(),
+ 'complete' => 'y',
+ 'grade' => 0,
+ ]);
+
+ // 4 remaining students are incomplete. Page by 2 gives two non-overlapping pages.
+ $cm = get_coursemodule_from_instance('questionnaire', $questionnaire->id(), $course->id, false, MUST_EXIST);
+ $page0 = questionnaire_responses::get_incomplete_users($cm, $questionnaire->surveyid(), false, '', 0, 2);
+ $page1 = questionnaire_responses::get_incomplete_users($cm, $questionnaire->surveyid(), false, '', 2, 2);
+
+ $this->assertIsArray($page0);
+ $this->assertCount(2, $page0);
+ $this->assertIsArray($page1);
+ $this->assertCount(2, $page1);
+ // No overlap between pages.
+ $this->assertEmpty(array_intersect($page0, $page1));
+ }
+
+ // Tests for count_distinct_responders().
+
+ /**
+ * Asserts count_distinct_responders() counts each user once regardless of how many responses they have.
+ *
+ * @covers \mod_questionnaire\local\response\questionnaire_responses::count_distinct_responders
+ */
+ public function test_count_distinct_responders_counts_unique_users(): void {
+ global $DB;
+ $this->resetAfterTest();
+ $this->setAdminUser();
+
+ $generator = $this->getDataGenerator();
+ $course = $generator->create_course();
+ $questionnaire = $generator->get_plugin_generator('mod_questionnaire')
+ ->create_instance(['course' => $course->id]);
+
+ $u1 = $generator->create_user();
+ $u2 = $generator->create_user();
+ $u3 = $generator->create_user();
+
+ // User u1 has two responses; should still count once.
+ $DB->insert_record('questionnaire_response', (object)[
+ 'questionnaireid' => $questionnaire->id(),
+ 'userid' => $u1->id, 'submitted' => time(), 'complete' => 'y', 'grade' => 0,
+ ]);
+ $DB->insert_record('questionnaire_response', (object)[
+ 'questionnaireid' => $questionnaire->id(),
+ 'userid' => $u1->id, 'submitted' => time(), 'complete' => 'n', 'grade' => 0,
+ ]);
+ $DB->insert_record('questionnaire_response', (object)[
+ 'questionnaireid' => $questionnaire->id(),
+ 'userid' => $u2->id, 'submitted' => time(), 'complete' => 'y', 'grade' => 0,
+ ]);
+ $DB->insert_record('questionnaire_response', (object)[
+ 'questionnaireid' => $questionnaire->id(),
+ 'userid' => $u3->id, 'submitted' => time(), 'complete' => 'y', 'grade' => 0,
+ ]);
+
+ $this->assertSame(3, $questionnaire->responses()->count_distinct_responders());
+ }
+
+ /**
+ * Asserts count_distinct_responders() restricts the count to members of the given groups.
+ *
+ * @covers \mod_questionnaire\local\response\questionnaire_responses::count_distinct_responders
+ */
+ public function test_count_distinct_responders_respects_group_filter(): void {
+ global $DB;
+ $this->resetAfterTest();
+ $this->setAdminUser();
+
+ $generator = $this->getDataGenerator();
+ $course = $generator->create_course();
+ $questionnaire = $generator->get_plugin_generator('mod_questionnaire')
+ ->create_instance(['course' => $course->id]);
+
+ $u1 = $generator->create_user();
+ $u2 = $generator->create_user();
+ $u3 = $generator->create_user();
+
+ foreach ([$u1, $u2, $u3] as $u) {
+ $DB->insert_record('questionnaire_response', (object)[
+ 'questionnaireid' => $questionnaire->id(),
+ 'userid' => $u->id, 'submitted' => time(), 'complete' => 'y', 'grade' => 0,
+ ]);
+ }
+
+ $group = $generator->create_group(['courseid' => $course->id]);
+ $DB->insert_record('groups_members', (object)['groupid' => $group->id, 'userid' => $u1->id]);
+ $DB->insert_record('groups_members', (object)['groupid' => $group->id, 'userid' => $u2->id]);
+
+ $this->assertSame(2, $questionnaire->responses()->count_distinct_responders([$group->id]));
+ $this->assertSame(3, $questionnaire->responses()->count_distinct_responders());
+ }
+
+ /**
+ * Asserts count_distinct_responders() returns zero when no responses exist.
+ *
+ * @covers \mod_questionnaire\local\response\questionnaire_responses::count_distinct_responders
+ */
+ public function test_count_distinct_responders_returns_zero_when_empty(): void {
+ $this->resetAfterTest();
+ $this->setAdminUser();
+
+ $generator = $this->getDataGenerator();
+ $course = $generator->create_course();
+ $questionnaire = $generator->get_plugin_generator('mod_questionnaire')
+ ->create_instance(['course' => $course->id]);
+
+ $this->assertSame(0, $questionnaire->responses()->count_distinct_responders());
+ }
+
+ // Tests for response_select_max_sec().
+
+ /**
+ * Build a fixture with a Q1, pagebreak, Q2, pagebreak, Q3 layout at explicit positions 1..5.
+ * Inserts questions directly via $DB so positions are deterministic for section-math tests.
+ *
+ * @return array [questionnaire, [q1id, q2id, q3id]]
+ */
+ private function build_three_section_questionnaire(): array {
+ global $DB;
+ $this->setAdminUser();
+ $generator = $this->getDataGenerator();
+ $course = $generator->create_course();
+ $questionnaire = $generator->get_plugin_generator('mod_questionnaire')
+ ->create_instance(['course' => $course->id]);
+ $sid = $questionnaire->surveyid();
+
+ $mkrow = function (int $surveyid, int $position, int $typeid, string $name) use ($DB): int {
+ return $DB->insert_record('questionnaire_question', (object)[
+ 'surveyid' => $surveyid,
+ 'name' => $name,
+ 'typeid' => $typeid,
+ 'length' => 0,
+ 'precise' => 0,
+ 'position' => $position,
+ 'content' => $name . ' content',
+ 'required' => 'n',
+ 'deleted' => null,
+ ]);
+ };
+
+ $q1id = $mkrow($sid, 1, QUESYESNO, 'Q1');
+ $mkrow($sid, 2, QUESPAGEBREAK, 'PB1');
+ $q2id = $mkrow($sid, 3, QUESYESNO, 'Q2');
+ $mkrow($sid, 4, QUESPAGEBREAK, 'PB2');
+ $q3id = $mkrow($sid, 5, QUESYESNO, 'Q3');
+
+ // Reload so questions_by_section_all is correct for the new layout.
+ $questionnaire = \mod_questionnaire\questionnaire::from_instanceid($questionnaire->id());
+ return [$questionnaire, [$q1id, $q2id, $q3id]];
+ }
+
+ /**
+ * Insert a yes/no answer row tying a response to a question.
+ *
+ * @param int $responseid
+ * @param int $questionid
+ * @return void
+ */
+ private function insert_bool_answer(int $responseid, int $questionid): void {
+ global $DB;
+ $DB->insert_record('questionnaire_response_bool', (object)[
+ 'responseid' => $responseid,
+ 'questionid' => $questionid,
+ 'choiceid' => 'y',
+ ]);
+ }
+
+ /**
+ * Asserts response_select_max_sec() returns 1 when the response has no answers.
+ *
+ * @covers \mod_questionnaire\local\response\questionnaire_responses::response_select_max_sec
+ */
+ public function test_response_select_max_sec_returns_one_when_no_answers(): void {
+ $this->resetAfterTest();
+ [$questionnaire] = $this->build_three_section_questionnaire();
+ $student = $this->getDataGenerator()->create_user();
+
+ $response = \mod_questionnaire\local\response\response::create($questionnaire->id(), (int)$student->id);
+ $this->assertSame(1, $questionnaire->responses()->response_select_max_sec($response->id()));
+ }
+
+ /**
+ * Asserts response_select_max_sec() returns the section number containing the highest answered question.
+ *
+ * @covers \mod_questionnaire\local\response\questionnaire_responses::response_select_max_sec
+ */
+ public function test_response_select_max_sec_reflects_last_answered_section(): void {
+ $this->resetAfterTest();
+ [$questionnaire, $qids] = $this->build_three_section_questionnaire();
+ $student = $this->getDataGenerator()->create_user();
+
+ $r1 = \mod_questionnaire\local\response\response::create($questionnaire->id(), (int)$student->id);
+ $this->insert_bool_answer($r1->id(), $qids[0]);
+ $this->assertSame(1, $questionnaire->responses()->response_select_max_sec($r1->id()));
+
+ $r2 = \mod_questionnaire\local\response\response::create($questionnaire->id(), (int)$student->id);
+ $this->insert_bool_answer($r2->id(), $qids[1]);
+ $this->assertSame(2, $questionnaire->responses()->response_select_max_sec($r2->id()));
+
+ $r3 = \mod_questionnaire\local\response\response::create($questionnaire->id(), (int)$student->id);
+ $this->insert_bool_answer($r3->id(), $qids[2]);
+ $this->assertSame(3, $questionnaire->responses()->response_select_max_sec($r3->id()));
+ }
+}
diff --git a/tests/questionnaire_test.php b/tests/questionnaire_test.php
new file mode 100644
index 000000000..5999b278a
--- /dev/null
+++ b/tests/questionnaire_test.php
@@ -0,0 +1,649 @@
+.
+
+/**
+ * Unit tests for mod_questionnaire\questionnaire.
+ *
+ * The testable subclass bypasses the constructor (which requires a real course module)
+ * so that pure business logic can be tested without full Moodle fixture setup.
+ *
+ * Tests that require capabilities or a real context are marked TODO.
+ *
+ * @package mod_questionnaire
+ * @copyright 2025 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;
+
+use mod_questionnaire\local\db\questionnaire_record;
+use mod_questionnaire\local\db\survey_record;
+use mod_questionnaire\local\response\questionnaire_responses;
+use mod_questionnaire\local\survey\survey;
+
+defined('MOODLE_INTERNAL') || die();
+
+global $CFG;
+require_once($CFG->dirroot . '/mod/questionnaire/lib.php');
+require_once($CFG->dirroot . '/mod/questionnaire/tests/questionnaire_testable.php');
+
+/**
+ * Unit tests for mod_questionnaire\questionnaire.
+ *
+ * @group mod_questionnaire
+ * @covers \mod_questionnaire\questionnaire
+ */
+final class questionnaire_test extends \advanced_testcase {
+ // Helpers.
+
+ /**
+ * Build a questionnaire_record persistent populated from an in-memory object
+ * (no DB read). Pass $fields to override any default.
+ *
+ * @param array $fields
+ * @return questionnaire_record
+ */
+ private function make_module_record(array $fields = []): questionnaire_record {
+ return new questionnaire_record(0, (object)array_merge([
+ 'id' => 1,
+ 'course' => 1,
+ 'name' => 'Test questionnaire',
+ 'intro' => '',
+ 'introformat' => FORMAT_HTML,
+ 'qtype' => questionnaire::QTYPE_UNLIMITED,
+ 'respondenttype' => 'fullname',
+ 'respeligible' => 'all',
+ 'respview' => questionnaire::RESPVIEW_NEVER,
+ 'notifications' => 0,
+ 'opendate' => 0,
+ 'closedate' => 0,
+ 'resume' => 0,
+ 'navigate' => 0,
+ 'grade' => 0,
+ 'sid' => 1,
+ 'timemodified' => 0,
+ 'completionsubmit' => 0,
+ 'autonum' => 3,
+ 'progressbar' => 0,
+ 'removeafter' => 0,
+ ], $fields));
+ }
+
+ /**
+ * Build a survey_record persistent from an in-memory object (no DB read).
+ *
+ * @param array $fields
+ * @return survey_record
+ */
+ private function make_survey_record(array $fields = []): survey_record {
+ return new survey_record(0, (object)array_merge([
+ 'id' => 1,
+ 'name' => 'Test survey',
+ 'courseid' => 1,
+ 'realm' => 'private',
+ 'status' => 0,
+ 'title' => '',
+ 'email' => null,
+ 'subtitle' => null,
+ 'info' => null,
+ 'theme' => null,
+ 'thankspage' => null,
+ 'thankhead' => null,
+ 'thankbody' => null,
+ 'feedbacksections' => 0,
+ 'feedbacknotes' => null,
+ 'feedbackscores' => 0,
+ 'charttype' => null,
+ ], $fields));
+ }
+
+ /**
+ * Return a questionnaire_testable with optional field overrides.
+ *
+ * @param array $modulefields Overrides for questionnaire_record.
+ * @param array $surveyfields Overrides for survey_record.
+ * @return questionnaire_testable
+ */
+ private function make_questionnaire(array $modulefields = [], array $surveyfields = []): questionnaire_testable {
+ if (!array_key_exists('id', $surveyfields)) {
+ $surveyfields['id'] = $modulefields['sid'] ?? 1;
+ }
+ return new questionnaire_testable(
+ $this->make_module_record($modulefields),
+ $this->make_survey_record($surveyfields)
+ );
+ }
+
+ // Tests for is_active().
+
+ /**
+ * Asserts is_active() returns true when a survey is linked.
+ *
+ * @covers \mod_questionnaire\questionnaire::is_active
+ */
+ public function test_is_active_when_survey_linked(): void {
+ $this->assertTrue($this->make_questionnaire(['sid' => 5])->is_active());
+ }
+
+ /**
+ * Asserts is_active() returns false when no survey is linked.
+ *
+ * @covers \mod_questionnaire\questionnaire::is_active
+ */
+ public function test_is_active_when_no_survey(): void {
+ $this->assertFalse($this->make_questionnaire(['sid' => 0])->is_active());
+ }
+
+ // Tests for is_open().
+
+ /**
+ * Asserts is_open() returns true when no open date is set.
+ *
+ * @covers \mod_questionnaire\questionnaire::is_open
+ */
+ public function test_is_open_with_no_date(): void {
+ $this->assertTrue($this->make_questionnaire(['opendate' => 0])->is_open());
+ }
+
+ /**
+ * Asserts is_open() returns true when the open date is in the past.
+ *
+ * @covers \mod_questionnaire\questionnaire::is_open
+ */
+ public function test_is_open_with_past_open_date(): void {
+ $this->assertTrue($this->make_questionnaire(['opendate' => time() - 3600])->is_open());
+ }
+
+ /**
+ * Asserts is_open() returns false when the open date is in the future.
+ *
+ * @covers \mod_questionnaire\questionnaire::is_open
+ */
+ public function test_is_not_open_with_future_open_date(): void {
+ $this->assertFalse($this->make_questionnaire(['opendate' => time() + 3600])->is_open());
+ }
+
+ // Tests for is_closed().
+
+ /**
+ * Asserts is_closed() returns false when no close date is set.
+ *
+ * @covers \mod_questionnaire\questionnaire::is_closed
+ */
+ public function test_is_not_closed_with_no_date(): void {
+ $this->assertFalse($this->make_questionnaire(['closedate' => 0])->is_closed());
+ }
+
+ /**
+ * Asserts is_closed() returns true when the close date is in the past.
+ *
+ * @covers \mod_questionnaire\questionnaire::is_closed
+ */
+ public function test_is_closed_with_past_close_date(): void {
+ $this->assertTrue($this->make_questionnaire(['closedate' => time() - 3600])->is_closed());
+ }
+
+ /**
+ * Asserts is_closed() returns false when the close date is in the future.
+ *
+ * @covers \mod_questionnaire\questionnaire::is_closed
+ */
+ public function test_is_not_closed_with_future_close_date(): void {
+ $this->assertFalse($this->make_questionnaire(['closedate' => time() + 3600])->is_closed());
+ }
+
+ // Tests for is_anonymous().
+
+ /**
+ * Asserts is_anonymous() returns true when respondent type is anonymous.
+ *
+ * @covers \mod_questionnaire\questionnaire::is_anonymous
+ */
+ public function test_is_anonymous(): void {
+ $this->assertTrue($this->make_questionnaire(['respondenttype' => 'anonymous'])->is_anonymous());
+ }
+
+ /**
+ * Asserts is_anonymous() returns false when respondent type is fullname.
+ *
+ * @covers \mod_questionnaire\questionnaire::is_anonymous
+ */
+ public function test_is_not_anonymous(): void {
+ $this->assertFalse($this->make_questionnaire(['respondenttype' => 'fullname'])->is_anonymous());
+ }
+
+ // Tests for the survey_is_public and survey_is_template state methods.
+
+ /**
+ * Asserts survey_is_public() returns correct value based on realm.
+ *
+ * @covers \mod_questionnaire\questionnaire::survey_is_public
+ */
+ public function test_survey_is_public(): void {
+ $this->assertTrue($this->make_questionnaire([], ['realm' => 'public'])->survey_is_public());
+ $this->assertFalse($this->make_questionnaire([], ['realm' => 'private'])->survey_is_public());
+ $this->assertFalse($this->make_questionnaire([], ['realm' => 'template'])->survey_is_public());
+ }
+
+ // Tests for the survey_is_public_master and is_survey_owner state methods.
+
+ /**
+ * Asserts survey_is_public_master() returns true when courses match.
+ *
+ * @covers \mod_questionnaire\questionnaire::survey_is_public_master
+ */
+ public function test_survey_is_public_master_when_owning_course(): void {
+ $q = $this->make_questionnaire(['course' => 10], ['realm' => 'public', 'courseid' => 10]);
+ $this->assertTrue($q->survey_is_public_master());
+ }
+
+ /**
+ * Asserts survey_is_public_master() returns false when courses differ.
+ *
+ * @covers \mod_questionnaire\questionnaire::survey_is_public_master
+ */
+ public function test_survey_is_not_public_master_when_different_course(): void {
+ $q = $this->make_questionnaire(['course' => 10], ['realm' => 'public', 'courseid' => 99]);
+ $this->assertFalse($q->survey_is_public_master());
+ }
+
+ /**
+ * Asserts survey_is_public_master() returns false when survey is private.
+ *
+ * @covers \mod_questionnaire\questionnaire::survey_is_public_master
+ */
+ public function test_survey_is_not_public_master_when_private(): void {
+ $q = $this->make_questionnaire(['course' => 10], ['realm' => 'private', 'courseid' => 10]);
+ $this->assertFalse($q->survey_is_public_master());
+ }
+
+ /**
+ * Asserts is_survey_owner() returns true when questionnaire and survey share course.
+ *
+ * @covers \mod_questionnaire\questionnaire::is_survey_owner
+ */
+ public function test_is_survey_owner_when_same_course(): void {
+ $q = $this->make_questionnaire(['course' => 10], ['courseid' => 10]);
+ $this->assertTrue($q->is_survey_owner());
+ }
+
+ /**
+ * Asserts is_survey_owner() returns false when questionnaire and survey are in different courses.
+ *
+ * @covers \mod_questionnaire\questionnaire::is_survey_owner
+ */
+ public function test_is_not_survey_owner_when_different_course(): void {
+ $q = $this->make_questionnaire(['course' => 10], ['courseid' => 99]);
+ $this->assertFalse($q->is_survey_owner());
+ }
+
+ // Tests for user_has_saved_response().
+
+ /**
+ * Asserts user_has_saved_response() returns false when no saved response exists.
+ *
+ * @covers \mod_questionnaire\questionnaire::user_has_saved_response
+ */
+ public function test_user_has_no_saved_response(): void {
+ $this->resetAfterTest();
+ $q = $this->make_questionnaire(['id' => 99991]);
+ $this->assertFalse($q->user_has_saved_response(1));
+ }
+
+ /**
+ * Asserts user_has_saved_response() returns true when an incomplete response exists.
+ *
+ * @covers \mod_questionnaire\questionnaire::user_has_saved_response
+ */
+ public function test_user_has_saved_response(): void {
+ global $DB;
+ $this->resetAfterTest();
+
+ $qid = 99992;
+ $q = $this->make_questionnaire(['id' => $qid]);
+ $DB->insert_record('questionnaire_response', (object)[
+ 'questionnaireid' => $qid, 'userid' => 1,
+ 'submitted' => time(), 'complete' => 'n', 'grade' => 0,
+ ]);
+
+ $this->assertTrue($q->user_has_saved_response(1));
+ }
+
+ /**
+ * Complete responses do not count as "saved" (incomplete) responses.
+ * @covers \mod_questionnaire\questionnaire::user_has_saved_response
+ */
+ public function test_complete_response_is_not_saved_response(): void {
+ global $DB;
+ $this->resetAfterTest();
+
+ $qid = 99993;
+ $q = $this->make_questionnaire(['id' => $qid]);
+ $DB->insert_record('questionnaire_response', (object)[
+ 'questionnaireid' => $qid, 'userid' => 1,
+ 'submitted' => time(), 'complete' => 'y', 'grade' => 0,
+ ]);
+
+ $this->assertFalse($q->user_has_saved_response(1));
+ }
+
+ // Tests for user_access_messages() — cases that don't reach capability checks.
+
+ /**
+ * Asserts user_access_messages() returns the template-not-viewable string for template surveys.
+ *
+ * @covers \mod_questionnaire\questionnaire::user_access_messages
+ */
+ public function test_user_access_messages_template_not_viewable(): void {
+ $q = $this->make_questionnaire(['sid' => 5], ['realm' => 'template']);
+ $this->assertStringContainsString(
+ get_string('templatenotviewable', 'questionnaire'),
+ $q->user_access_messages()
+ );
+ }
+
+ /**
+ * Asserts user_access_messages() includes the open date when the questionnaire is not yet open.
+ *
+ * @covers \mod_questionnaire\questionnaire::user_access_messages
+ */
+ public function test_user_access_messages_not_open_yet(): void {
+ $opendate = time() + 7200;
+ $q = $this->make_questionnaire(['sid' => 5, 'opendate' => $opendate]);
+ $msg = $q->user_access_messages();
+ $this->assertNotNull($msg);
+ $this->assertStringContainsString(userdate($opendate), $msg);
+ }
+
+ /**
+ * Asserts user_access_messages() includes the close date when the questionnaire is closed.
+ *
+ * @covers \mod_questionnaire\questionnaire::user_access_messages
+ */
+ public function test_user_access_messages_closed(): void {
+ $closedate = time() - 3600;
+ $q = $this->make_questionnaire(['sid' => 5, 'closedate' => $closedate]);
+ $msg = $q->user_access_messages();
+ $this->assertNotNull($msg);
+ $this->assertStringContainsString(userdate($closedate), $msg);
+ }
+
+ // Tests for the navigate and pages_autonumbered accessors.
+
+ /**
+ * Asserts navigate() returns the raw integer from the module record.
+ *
+ * @covers \mod_questionnaire\questionnaire::navigate
+ */
+ public function test_navigate_returns_field_value(): void {
+ $this->assertEquals(0, $this->make_questionnaire(['navigate' => 0])->navigate());
+ $this->assertEquals(1, $this->make_questionnaire(['navigate' => 1])->navigate());
+ }
+
+ /**
+ * Asserts pages_autonumbered() returns true only for autonum values 2 and 3.
+ *
+ * @covers \mod_questionnaire\questionnaire::pages_autonumbered
+ */
+ public function test_pages_autonumbered(): void {
+ $this->assertFalse($this->make_questionnaire(['autonum' => 0])->pages_autonumbered());
+ $this->assertFalse($this->make_questionnaire(['autonum' => 1])->pages_autonumbered());
+ $this->assertTrue($this->make_questionnaire(['autonum' => 2])->pages_autonumbered());
+ $this->assertTrue($this->make_questionnaire(['autonum' => 3])->pages_autonumbered());
+ }
+
+ // Tests for responses().
+
+ /**
+ * Asserts responses() returns a questionnaire_responses instance.
+ *
+ * @covers \mod_questionnaire\questionnaire::responses
+ */
+ public function test_responses_returns_correct_type(): void {
+ $this->assertInstanceOf(questionnaire_responses::class, $this->make_questionnaire()->responses());
+ }
+
+ // Tests for get_responses().
+
+ /**
+ * Asserts get_responses() returns an empty array when no responses exist.
+ *
+ * @covers \mod_questionnaire\questionnaire::get_responses
+ */
+ public function test_get_responses_empty_when_none(): void {
+ $this->resetAfterTest();
+ $this->assertSame([], $this->make_questionnaire(['id' => 77771])->get_responses());
+ }
+
+ /**
+ * Asserts get_responses() returns all responses for the questionnaire.
+ *
+ * @covers \mod_questionnaire\questionnaire::get_responses
+ */
+ public function test_get_responses_returns_records(): void {
+ global $DB;
+ $this->resetAfterTest();
+ $qid = 77772;
+ $DB->insert_record('questionnaire_response', (object)[
+ 'questionnaireid' => $qid, 'userid' => 1,
+ 'submitted' => time(), 'complete' => 'y', 'grade' => 0,
+ ]);
+ $this->assertCount(1, $this->make_questionnaire(['id' => $qid])->get_responses());
+ }
+
+ /**
+ * Asserts get_responses() filters by userid when one is specified.
+ *
+ * @covers \mod_questionnaire\questionnaire::get_responses
+ */
+ public function test_get_responses_filtered_by_userid(): void {
+ global $DB;
+ $this->resetAfterTest();
+ $qid = 77773;
+ $q = $this->make_questionnaire(['id' => $qid]);
+ $DB->insert_record('questionnaire_response', (object)[
+ 'questionnaireid' => $qid, 'userid' => 1,
+ 'submitted' => time(), 'complete' => 'y', 'grade' => 0,
+ ]);
+ $DB->insert_record('questionnaire_response', (object)[
+ 'questionnaireid' => $qid, 'userid' => 2,
+ 'submitted' => time(), 'complete' => 'y', 'grade' => 0,
+ ]);
+ $this->assertCount(1, $q->get_responses(1));
+ $this->assertCount(1, $q->get_responses(2));
+ $this->assertCount(2, $q->get_responses(false));
+ }
+
+ // Tests for get_latest_responseid().
+
+ /**
+ * Asserts get_latest_responseid() returns 0 when no responses exist.
+ *
+ * @covers \mod_questionnaire\questionnaire::get_latest_responseid
+ */
+ public function test_get_latest_responseid_returns_zero_when_none(): void {
+ $this->resetAfterTest();
+ $this->assertSame(0, $this->make_questionnaire(['id' => 66661])->get_latest_responseid(1));
+ }
+
+ /**
+ * Asserts get_latest_responseid() returns the id of the user's incomplete response.
+ *
+ * @covers \mod_questionnaire\questionnaire::get_latest_responseid
+ */
+ public function test_get_latest_responseid_returns_id_of_incomplete(): void {
+ global $DB;
+ $this->resetAfterTest();
+ $qid = 66662;
+ $rid = $DB->insert_record('questionnaire_response', (object)[
+ 'questionnaireid' => $qid, 'userid' => 1,
+ 'submitted' => time(), 'complete' => 'n', 'grade' => 0,
+ ]);
+ $this->assertSame((int)$rid, $this->make_questionnaire(['id' => $qid])->get_latest_responseid(1));
+ }
+
+ /**
+ * Asserts get_latest_responseid() returns 0 when only complete responses exist.
+ *
+ * @covers \mod_questionnaire\questionnaire::get_latest_responseid
+ */
+ public function test_get_latest_responseid_ignores_complete_responses(): void {
+ global $DB;
+ $this->resetAfterTest();
+ $qid = 66663;
+ $DB->insert_record('questionnaire_response', (object)[
+ 'questionnaireid' => $qid, 'userid' => 1,
+ 'submitted' => time(), 'complete' => 'y', 'grade' => 0,
+ ]);
+ $this->assertSame(0, $this->make_questionnaire(['id' => $qid])->get_latest_responseid(1));
+ }
+
+ /**
+ * Asserts get_latest_responseid() returns the most recently submitted incomplete response.
+ *
+ * @covers \mod_questionnaire\questionnaire::get_latest_responseid
+ */
+ public function test_get_latest_responseid_returns_most_recent(): void {
+ global $DB;
+ $this->resetAfterTest();
+ $qid = 66664;
+ $DB->insert_record('questionnaire_response', (object)[
+ 'questionnaireid' => $qid, 'userid' => 1,
+ 'submitted' => time() - 7200, 'complete' => 'n', 'grade' => 0,
+ ]);
+ $latestid = $DB->insert_record('questionnaire_response', (object)[
+ 'questionnaireid' => $qid, 'userid' => 1,
+ 'submitted' => time() - 3600, 'complete' => 'n', 'grade' => 0,
+ ]);
+ $this->assertSame((int)$latestid, $this->make_questionnaire(['id' => $qid])->get_latest_responseid(1));
+ }
+
+ /*
+ * TODO: tests for get_notifiable_users().
+ *
+ * get_notifiable_users() requires get_enrolled_users() and groups_get_activity_groupmode(),
+ * which need a real course module and context. These tests should be added once the
+ * generator creates instances through the full add_instance pathway.
+ */
+
+ // Helpers for dependency tests.
+
+ /**
+ * Create a minimal question stub with the given id and dependencies array.
+ *
+ * Uses an anonymous class to avoid instantiating the real question hierarchy
+ * (which requires a DB-backed record).
+ *
+ * @param int $id
+ * @param array $dependencies Keyed dependency stdClass objects (dependquestionid etc.)
+ * @return object
+ */
+ private function make_stub_question(int $id, array $dependencies): object {
+ return new class ($id, $dependencies) {
+ /** @var int */
+ public int $id;
+ /** @var array */
+ public array $dependencies;
+ /** @var int */
+ public int $position = 0;
+ /** @var string */
+ public string $name = '';
+ /** @var string */
+ public string $content = '';
+ /** @var int */
+ public int $typeid = 0;
+
+ /**
+ * Constructor.
+ * @param int $id
+ * @param array $dependencies
+ */
+ public function __construct(int $id, array $dependencies) {
+ $this->id = $id;
+ $this->dependencies = $dependencies;
+ }
+
+ /**
+ * True if dependencies are non-empty.
+ * @return bool
+ */
+ public function has_dependencies(): bool {
+ return !empty($this->dependencies);
+ }
+
+ /**
+ * Return the question id.
+ * @return int
+ */
+ public function id(): int {
+ return $this->id;
+ }
+
+ /**
+ * Always fulfilled when no dependencies are set (used in navigation tests).
+ * @param mixed $rid
+ * @param mixed $questions
+ * @return bool
+ */
+ public function dependency_fulfilled($rid, $questions): bool {
+ return !$this->has_dependencies();
+ }
+ };
+ }
+
+ // Tests for the default_page_count, confirm_delete_param and restore_param behavior helpers.
+
+ /**
+ * Asserts default_page_count() returns the expected default value of 20.
+ *
+ * @covers \mod_questionnaire\questionnaire::default_page_count
+ */
+ public function test_default_page_count_returns_twenty(): void {
+ $this->assertEquals(20, questionnaire::default_page_count());
+ }
+
+ /**
+ * Asserts confirm_delete_param() returns the expected URL parameter string.
+ *
+ * @covers \mod_questionnaire\questionnaire::confirm_delete_param
+ */
+ public function test_confirm_delete_param_returns_expected_string(): void {
+ $this->assertEquals(questionnaire::CONFIRM_DELETE_PERMANENTLY, questionnaire::confirm_delete_param());
+ }
+
+ /**
+ * Asserts restore_param() returns the expected URL parameter string.
+ *
+ * @covers \mod_questionnaire\questionnaire::restore_param
+ */
+ public function test_restore_param_returns_expected_string(): void {
+ $this->assertEquals(questionnaire::RESTORE_PARAM, questionnaire::restore_param());
+ }
+
+ // Tests for question_deletion_duration().
+
+ /**
+ * Asserts question_deletion_duration() returns the value from plugin config.
+ *
+ * @covers \mod_questionnaire\questionnaire::question_deletion_duration
+ */
+ public function test_question_deletion_duration_returns_configured_value(): void {
+ $this->resetAfterTest();
+ set_config('duration', 7 * DAYSECS, 'questionnaire_questiondeletion');
+ $this->assertEquals(7 * DAYSECS, survey::question_deletion_duration());
+ }
+}
diff --git a/tests/questionnaire_testable.php b/tests/questionnaire_testable.php
new file mode 100644
index 000000000..15c75f8ad
--- /dev/null
+++ b/tests/questionnaire_testable.php
@@ -0,0 +1,63 @@
+.
+
+namespace mod_questionnaire;
+
+use mod_questionnaire\local\db\questionnaire_record;
+use mod_questionnaire\local\db\survey_record;
+
+/**
+ * Testable subclass of questionnaire that bypasses the real constructor.
+ *
+ * Allows injecting questionnaire_record and survey_record directly so
+ * business-logic methods can be unit tested without a real course module.
+ *
+ * @package mod_questionnaire
+ * @copyright 2025 Mike Churchward (mike.churchward@poetopensource.org)
+ * @author Mike Churchward
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class questionnaire_testable extends questionnaire {
+ /**
+ * Construct with injected records, bypassing the real questionnaire constructor.
+ *
+ * @param questionnaire_record $modulerecord
+ * @param survey_record $surveyrecord
+ */
+ public function __construct(questionnaire_record $modulerecord, survey_record $surveyrecord) {
+ require_once(__DIR__ . '/local/survey/survey_testable.php');
+ $this->modulerecord = $modulerecord;
+ $this->survey = new \mod_questionnaire\local\survey\survey_testable($surveyrecord);
+ }
+
+ /**
+ * Inject question objects for unit tests (bypasses DB loading).
+ *
+ * @param array $questions Keyed by question id.
+ */
+ public function set_questions(array $questions): void {
+ $this->survey->set_questions($questions);
+ }
+
+ /**
+ * Inject the questions-by-section map for unit tests.
+ *
+ * @param array $questionsbysec Array of question object arrays, keyed by 1-based section number.
+ */
+ public function set_questions_by_sec(array $questionsbysec): void {
+ $this->survey->set_questions_by_sec($questionsbysec);
+ }
+}
diff --git a/tests/questiontypes_test.php b/tests/questiontypes_test.php
index 6f23fa120..e2c8defa0 100644
--- a/tests/questiontypes_test.php
+++ b/tests/questiontypes_test.php
@@ -25,13 +25,12 @@
namespace mod_questionnaire;
-use mod_questionnaire\question\question;
+use mod_questionnaire\local\question\question;
defined('MOODLE_INTERNAL') || die();
global $CFG;
-require_once($CFG->dirroot . '/mod/questionnaire/locallib.php');
-require_once($CFG->dirroot . '/mod/questionnaire/classes/question/question.php');
+require_once($CFG->dirroot . '/mod/questionnaire/classes/local/question/question.php');
/**
* Unit tests for questionnaire_questiontypes_testcase.
@@ -48,7 +47,7 @@ final class questiontypes_test extends \advanced_testcase {
public function test_create_question_checkbox(): void {
$this->create_test_question_with_choices(
QUESCHECK,
- '\\mod_questionnaire\\question\\check',
+ '\\mod_questionnaire\\local\\question\\check',
['content' => 'Check one']
);
}
@@ -61,7 +60,7 @@ public function test_create_question_checkbox(): void {
* @covers \mod_questionnaire\questiontypes_test::create_test_question
*/
public function test_create_question_date(): void {
- $this->create_test_question(QUESDATE, '\\mod_questionnaire\\question\\date', ['content' => 'Enter a date']);
+ $this->create_test_question(QUESDATE, '\\mod_questionnaire\\local\\question\\date', ['content' => 'Enter a date']);
}
/**
@@ -72,7 +71,11 @@ public function test_create_question_date(): void {
* @covers \mod_questionnaire\questiontypes_test::create_test_question
*/
public function test_create_question_dropdown(): void {
- $this->create_test_question_with_choices(QUESDROP, '\\mod_questionnaire\\question\\drop', ['content' => 'Select one']);
+ $this->create_test_question_with_choices(
+ QUESDROP,
+ '\\mod_questionnaire\\local\\question\\drop',
+ ['content' => 'Select one']
+ );
}
/**
@@ -88,7 +91,7 @@ public function test_create_question_essay(): void {
'length' => 0,
'precise' => 5,
];
- $this->create_test_question(QUESESSAY, '\\mod_questionnaire\\question\\essay', $questiondata);
+ $this->create_test_question(QUESESSAY, '\\mod_questionnaire\\local\\question\\essay', $questiondata);
}
/**
@@ -101,7 +104,7 @@ public function test_create_question_essay(): void {
public function test_create_question_sectiontext(): void {
$this->create_test_question(
QUESSECTIONTEXT,
- '\\mod_questionnaire\\question\\sectiontext',
+ '\\mod_questionnaire\\local\\question\\sectiontext',
['name' => null, 'content' => 'This a section label.']
);
}
@@ -119,7 +122,7 @@ public function test_create_question_numeric(): void {
'length' => 10,
'precise' => 0,
];
- $this->create_test_question(QUESNUMERIC, '\\mod_questionnaire\\question\\numerical', $questiondata);
+ $this->create_test_question(QUESNUMERIC, '\\mod_questionnaire\\local\\question\\numerical', $questiondata);
}
/**
@@ -130,7 +133,11 @@ public function test_create_question_numeric(): void {
* @covers \mod_questionnaire\questiontypes_test::create_test_question
*/
public function test_create_question_radiobuttons(): void {
- $this->create_test_question_with_choices(QUESRADIO, '\\mod_questionnaire\\question\\radio', ['content' => 'Choose one']);
+ $this->create_test_question_with_choices(
+ QUESRADIO,
+ '\\mod_questionnaire\\local\\question\\radio',
+ ['content' => 'Choose one']
+ );
}
/**
@@ -141,7 +148,11 @@ public function test_create_question_radiobuttons(): void {
* @covers \mod_questionnaire\questiontypes_test::create_test_question
*/
public function test_create_question_ratescale(): void {
- $this->create_test_question_with_choices(QUESRATE, '\\mod_questionnaire\\question\\rate', ['content' => 'Rate these']);
+ $this->create_test_question_with_choices(
+ QUESRATE,
+ '\\mod_questionnaire\\local\\question\\rate',
+ ['content' => 'Rate these']
+ );
}
/**
@@ -157,7 +168,7 @@ public function test_create_question_textbox(): void {
'length' => 20,
'precise' => 25,
];
- $this->create_test_question(QUESTEXT, '\\mod_questionnaire\\question\\text', $questiondata);
+ $this->create_test_question(QUESTEXT, '\\mod_questionnaire\\local\\question\\text', $questiondata);
}
/**
@@ -169,7 +180,7 @@ public function test_create_question_textbox(): void {
*/
public function test_create_question_slider(): void {
$questiondata = ['content' => 'Enter a number'];
- $this->create_test_question(QUESSLIDER, '\\mod_questionnaire\\question\\slider', $questiondata);
+ $this->create_test_question(QUESSLIDER, '\\mod_questionnaire\\local\\question\\slider', $questiondata);
}
/**
@@ -180,7 +191,7 @@ public function test_create_question_slider(): void {
* @covers \mod_questionnaire\questiontypes_test::create_test_question
*/
public function test_create_question_yesno(): void {
- $this->create_test_question(QUESYESNO, '\\mod_questionnaire\\question\\yesno', ['content' => 'Enter yes or no']);
+ $this->create_test_question(QUESYESNO, '\\mod_questionnaire\\local\\question\\yesno', ['content' => 'Enter yes or no']);
}
@@ -201,20 +212,19 @@ private function create_test_question($qtype, $questionclass, $questiondata = []
$course = $this->getDataGenerator()->create_course();
$generator = $this->getDataGenerator()->get_plugin_generator('mod_questionnaire');
$questionnaire = $generator->create_instance(['course' => $course->id]);
- $cm = get_coursemodule_from_instance('questionnaire', $questionnaire->id);
- $questiondata['type_id'] = $qtype;
- $questiondata['surveyid'] = $questionnaire->sid;
+ $questiondata['typeid'] = $qtype;
+ $questiondata['surveyid'] = $questionnaire->surveyid();
$questiondata['name'] = isset($questiondata['name']) ? $questiondata['name'] : 'Q1';
$questiondata['content'] = isset($questiondata['content']) ? $questiondata['content'] : 'Test content';
$question = $generator->create_question($questionnaire, $questiondata, $choicedata);
$this->assertInstanceOf($questionclass, $question);
- $this->assertTrue($question->id > 0);
+ $this->assertTrue($question->id() > 0);
// Question object retrieved from the database should have correct data.
- $this->assertEquals($question->type_id, $qtype);
+ $this->assertEquals($question->typeid(), $qtype);
foreach ($questiondata as $property => $value) {
- $this->assertEquals($question->$property, $value);
+ $this->assertEquals($question->$property(), $value);
}
if ($question->has_choices()) {
$this->assertEquals('array', gettype($question->choices));
@@ -229,13 +239,14 @@ private function create_test_question($qtype, $questionclass, $questiondata = []
}
// Questionnaire object should now have question record(s).
- $questionnaire = new \questionnaire($course, $cm, $questionnaire->id, null, true);
- $this->assertTrue($DB->record_exists('questionnaire_question', ['id' => $question->id]));
- $this->assertEquals('array', gettype($questionnaire->questions));
- $this->assertTrue(array_key_exists($question->id, $questionnaire->questions));
- $this->assertEquals(1, count($questionnaire->questions));
- if ($questionnaire->questions[$question->id]->has_choices()) {
- $this->assertEquals(count($choicedata), count($questionnaire->questions[$question->id]->choices));
+ $questionnaire = \mod_questionnaire\questionnaire::from_instanceid($questionnaire->id());
+ $this->assertTrue($DB->record_exists('questionnaire_question', ['id' => $question->id()]));
+ $questions = $questionnaire->questions();
+ $this->assertEquals('array', gettype($questions));
+ $this->assertTrue(array_key_exists($question->id(), $questions));
+ $this->assertEquals(1, count($questions));
+ if ($questions[$question->id()]->has_choices()) {
+ $this->assertEquals(count($choicedata), count($questions[$question->id()]->choices));
}
}
diff --git a/tests/responsetypes_test.php b/tests/responsetypes_test.php
index 88995ff70..a0b9fd5ca 100644
--- a/tests/responsetypes_test.php
+++ b/tests/responsetypes_test.php
@@ -25,15 +25,14 @@
namespace mod_questionnaire;
-use mod_questionnaire\question\question;
+use mod_questionnaire\local\question\question;
defined('MOODLE_INTERNAL') || die();
global $CFG;
-require_once($CFG->dirroot . '/mod/questionnaire/locallib.php');
require_once($CFG->dirroot . '/mod/questionnaire/tests/generator_test.php');
require_once($CFG->dirroot . '/mod/questionnaire/tests/questiontypes_test.php');
-require_once($CFG->dirroot . '/mod/questionnaire/classes/question/question.php');
+require_once($CFG->dirroot . '/mod/questionnaire/classes/local/question/question.php');
/**
* Unit tests for questionnaire_responsetypes_testcase.
@@ -46,7 +45,7 @@ final class responsetypes_test extends \advanced_testcase {
* @return void
* @throws dml_exception
*
- * @covers \mod_questionnaire\question\yesno
+ * @covers \mod_questionnaire\local\question\yesno
*/
public function test_create_response_boolean(): void {
global $DB;
@@ -60,18 +59,19 @@ public function test_create_response_boolean(): void {
$course = $this->getDataGenerator()->create_course();
$generator = $this->getDataGenerator()->get_plugin_generator('mod_questionnaire');
$questionnaire = $generator->create_test_questionnaire($course, QUESYESNO, ['content' => 'Enter yes or no']);
- $question = reset($questionnaire->questions);
+ $questions = $questionnaire->questions();
+ $question = reset($questions);
$response = $generator->create_question_response($questionnaire, $question, 'y', $userid);
// Test the responses for this questionnaire.
- $this->response_tests($questionnaire->id, $response->id, $userid);
+ $this->response_tests($questionnaire->id(), $response->id, $userid);
// Retrieve the specific boolean response.
- $booleanresponses = $DB->get_records('questionnaire_response_bool', ['response_id' => $response->id]);
+ $booleanresponses = $DB->get_records('questionnaire_response_bool', ['responseid' => $response->id]);
$this->assertEquals(1, count($booleanresponses));
$booleanresponse = reset($booleanresponses);
- $this->assertEquals($question->id, $booleanresponse->question_id);
- $this->assertEquals('y', $booleanresponse->choice_id);
+ $this->assertEquals($question->id(), $booleanresponse->questionid);
+ $this->assertEquals('y', $booleanresponse->choiceid);
}
/**
@@ -80,7 +80,7 @@ public function test_create_response_boolean(): void {
* @return void
* @throws dml_exception
*
- * @covers \mod_questionnaire\question\essay
+ * @covers \mod_questionnaire\local\question\essay
*/
public function test_create_response_text(): void {
global $DB;
@@ -95,17 +95,18 @@ public function test_create_response_text(): void {
$generator = $this->getDataGenerator()->get_plugin_generator('mod_questionnaire');
$questiondata = ['content' => 'Enter some text', 'length' => 0, 'precise' => 5];
$questionnaire = $generator->create_test_questionnaire($course, QUESESSAY, $questiondata);
- $question = reset($questionnaire->questions);
+ $questions = $questionnaire->questions();
+ $question = reset($questions);
$response = $generator->create_question_response($questionnaire, $question, 'This is my essay.', $userid);
// Test the responses for this questionnaire.
- $this->response_tests($questionnaire->id, $response->id, $userid);
+ $this->response_tests($questionnaire->id(), $response->id, $userid);
// Retrieve the specific text response.
- $textresponses = $DB->get_records('questionnaire_response_text', ['response_id' => $response->id]);
+ $textresponses = $DB->get_records('questionnaire_response_text', ['responseid' => $response->id]);
$this->assertEquals(1, count($textresponses));
$textresponse = reset($textresponses);
- $this->assertEquals($question->id, $textresponse->question_id);
+ $this->assertEquals($question->id(), $textresponse->questionid);
$this->assertEquals('This is my essay.', $textresponse->response);
}
@@ -115,7 +116,7 @@ public function test_create_response_text(): void {
* @return void
* @throws dml_exception
*
- * @covers \mod_questionnaire\question\slider
+ * @covers \mod_questionnaire\local\question\slider
*/
public function test_create_response_slider(): void {
global $DB;
@@ -130,17 +131,18 @@ public function test_create_response_slider(): void {
$generator = $this->getDataGenerator()->get_plugin_generator('mod_questionnaire');
$questiondata = ['content' => 'Enter some text'];
$questionnaire = $generator->create_test_questionnaire($course, QUESSLIDER, $questiondata);
- $question = reset($questionnaire->questions);
+ $questions = $questionnaire->questions();
+ $question = reset($questions);
$response = $generator->create_question_response($questionnaire, $question, 5, $userid);
// Test the responses for this questionnaire.
- $this->response_tests($questionnaire->id, $response->id, $userid);
+ $this->response_tests($questionnaire->id(), $response->id, $userid);
// Retrieve the specific text response.
- $textresponses = $DB->get_records('questionnaire_response_text', ['response_id' => $response->id]);
+ $textresponses = $DB->get_records('questionnaire_response_text', ['responseid' => $response->id]);
$this->assertEquals(1, count($textresponses));
$textresponse = reset($textresponses);
- $this->assertEquals($question->id, $textresponse->question_id);
+ $this->assertEquals($question->id(), $textresponse->questionid);
$this->assertEquals(5, $textresponse->response);
}
@@ -150,7 +152,7 @@ public function test_create_response_slider(): void {
* @return void
* @throws dml_exception
*
- * @covers \mod_questionnaire\question\date
+ * @covers \mod_questionnaire\local\question\date
*/
public function test_create_response_date(): void {
global $DB;
@@ -164,18 +166,19 @@ public function test_create_response_date(): void {
$course = $this->getDataGenerator()->create_course();
$generator = $this->getDataGenerator()->get_plugin_generator('mod_questionnaire');
$questionnaire = $generator->create_test_questionnaire($course, QUESDATE, ['content' => 'Enter a date']);
- $question = reset($questionnaire->questions);
+ $questions = $questionnaire->questions();
+ $question = reset($questions);
// Date format is configured per site. This won't work unless it matches the configured format.
$response = $generator->create_question_response($questionnaire, $question, '2015-01-27', $userid);
// Test the responses for this questionnaire.
- $this->response_tests($questionnaire->id, $response->id, $userid);
+ $this->response_tests($questionnaire->id(), $response->id, $userid);
// Retrieve the specific date response.
- $dateresponses = $DB->get_records('questionnaire_response_date', ['response_id' => $response->id]);
+ $dateresponses = $DB->get_records('questionnaire_response_date', ['responseid' => $response->id]);
$this->assertEquals(1, count($dateresponses));
$dateresponse = reset($dateresponses);
- $this->assertEquals($question->id, $dateresponse->question_id);
+ $this->assertEquals($question->id(), $dateresponse->questionid);
// The date is always stored in the database in the same way.
$this->assertEquals('2015-01-27', $dateresponse->response);
}
@@ -186,7 +189,7 @@ public function test_create_response_date(): void {
* @return void
* @throws dml_exception
*
- * @covers \mod_questionnaire\question\radio
+ * @covers \mod_questionnaire\local\question\radio
*/
public function test_create_response_single(): void {
global $DB;
@@ -208,7 +211,8 @@ public function test_create_response_single(): void {
$questionnaire = $generator->create_test_questionnaire($course, QUESRADIO, ['content' => 'Select one'], $choicedata);
// Create a response using one of the choices.
- $question = reset($questionnaire->questions);
+ $questions = $questionnaire->questions();
+ $question = reset($questions);
$val = 'unknown';
foreach ($question->choices as $cid => $choice) {
if ($choice->content == 'Two') {
@@ -218,14 +222,14 @@ public function test_create_response_single(): void {
$response = $generator->create_question_response($questionnaire, $question, $val, $userid);
// Test the responses for this questionnaire.
- $this->response_tests($questionnaire->id, $response->id, $userid);
+ $this->response_tests($questionnaire->id(), $response->id, $userid);
// Retrieve the specific single response.
- $singresponses = $DB->get_records('questionnaire_resp_single', ['response_id' => $response->id]);
+ $singresponses = $DB->get_records('questionnaire_resp_single', ['responseid' => $response->id]);
$this->assertEquals(1, count($singresponses));
$singresponse = reset($singresponses);
- $this->assertEquals($question->id, $singresponse->question_id);
- $this->assertEquals($val, $singresponse->choice_id);
+ $this->assertEquals($question->id(), $singresponse->questionid);
+ $this->assertEquals($val, $singresponse->choiceid);
// Create another response using the '!other' choice.
foreach ($question->choices as $cid => $choice) {
@@ -233,29 +237,29 @@ public function test_create_response_single(): void {
$val = $cid;
}
}
- $vals = ['q' . $question->id => $val,
- 'q' . $question->id . \mod_questionnaire\question\choice::id_other_choice_name($val) => 'Forty-four'];
+ $vals = ['q' . $question->id() => $val,
+ 'q' . $question->id() . \mod_questionnaire\local\question\choice::id_other_choice_name($val) => 'Forty-four'];
$userid = 2;
$response = $generator->create_question_response($questionnaire, $question, $vals, $userid);
// Test the responses for this questionnaire.
- $this->response_tests($questionnaire->id, $response->id, $userid, 1, 2);
+ $this->response_tests($questionnaire->id(), $response->id, $userid, 1, 2);
// Retrieve the specific single response.
- $singresponses = $DB->get_records('questionnaire_resp_single', ['response_id' => $response->id]);
+ $singresponses = $DB->get_records('questionnaire_resp_single', ['responseid' => $response->id]);
$this->assertEquals(1, count($singresponses));
$singresponse = reset($singresponses);
- $this->assertEquals($question->id, $singresponse->question_id);
- $this->assertEquals($val, $singresponse->choice_id);
+ $this->assertEquals($question->id(), $singresponse->questionid);
+ $this->assertEquals($val, $singresponse->choiceid);
// Retrieve the 'other' response data.
$otherresponses = $DB->get_records(
'questionnaire_response_other',
- ['response_id' => $response->id, 'question_id' => $question->id]
+ ['responseid' => $response->id, 'questionid' => $question->id()]
);
$this->assertEquals(1, count($otherresponses));
$otherresponse = reset($otherresponses);
- $this->assertEquals($val, $otherresponse->choice_id);
+ $this->assertEquals($val, $otherresponse->choiceid);
$this->assertEquals('Forty-four', $otherresponse->response);
}
@@ -265,7 +269,7 @@ public function test_create_response_single(): void {
* @return void
* @throws dml_exception
*
- * @covers \mod_questionnaire\question\rate
+ * @covers \mod_questionnaire\local\question\rate
*/
public function test_create_response_multiple(): void {
global $DB;
@@ -286,41 +290,42 @@ public function test_create_response_multiple(): void {
];
$questionnaire = $generator->create_test_questionnaire($course, QUESCHECK, ['content' => 'Select any'], $choicedata);
- $question = reset($questionnaire->questions);
+ $questions = $questionnaire->questions();
+ $question = reset($questions);
$val = [];
foreach ($question->choices as $cid => $choice) {
if (($choice->content == 'Two') || ($choice->content == 'Three')) {
$val[$cid] = $cid;
} else if ($choice->content == '!other=Another number') {
$val[$cid] = $cid;
- $val[\mod_questionnaire\question\choice::id_other_choice_name($cid)] = 'Forty-four';
+ $val[\mod_questionnaire\local\question\choice::id_other_choice_name($cid)] = 'Forty-four';
$ocid = $cid;
}
}
- $vals = ['q' . $question->id => $val];
+ $vals = ['q' . $question->id() => $val];
$response = $generator->create_question_response($questionnaire, $question, $vals, $userid);
// Test the responses for this questionnaire.
- $this->response_tests($questionnaire->id, $response->id, $userid);
+ $this->response_tests($questionnaire->id(), $response->id, $userid);
// Retrieve the specific multiples responses.
- $multresponses = $DB->get_records('questionnaire_resp_multiple', ['response_id' => $response->id]);
+ $multresponses = $DB->get_records('questionnaire_resp_multiple', ['responseid' => $response->id]);
$this->assertEquals(3, count($multresponses));
$multresponse = reset($multresponses);
- $this->assertEquals($question->id, $multresponse->question_id);
- $this->assertEquals(reset($val), $multresponse->choice_id);
+ $this->assertEquals($question->id(), $multresponse->questionid);
+ $this->assertEquals(reset($val), $multresponse->choiceid);
$multresponse = next($multresponses);
- $this->assertEquals($question->id, $multresponse->question_id);
- $this->assertEquals(next($val), $multresponse->choice_id);
+ $this->assertEquals($question->id(), $multresponse->questionid);
+ $this->assertEquals(next($val), $multresponse->choiceid);
// Retrieve the specific other response.
$otherresponses = $DB->get_records(
'questionnaire_response_other',
- ['response_id' => $response->id, 'question_id' => $question->id]
+ ['responseid' => $response->id, 'questionid' => $question->id()]
);
$this->assertEquals(1, count($otherresponses));
$otherresponse = reset($otherresponses);
- $this->assertEquals($ocid, $otherresponse->choice_id);
+ $this->assertEquals($ocid, $otherresponse->choiceid);
$this->assertEquals('Forty-four', $otherresponse->response);
}
@@ -330,7 +335,7 @@ public function test_create_response_multiple(): void {
* @return void
* @throws dml_exception
*
- * @covers \mod_questionnaire\question\rate
+ * @covers \mod_questionnaire\local\question\rate
*/
public function test_create_response_rank(): void {
global $DB;
@@ -352,24 +357,25 @@ public function test_create_response_rank(): void {
$questionnaire = $generator->create_test_questionnaire($course, QUESRATE, $questiondata, $choicedata);
// Create a response for each choice.
- $question = reset($questionnaire->questions);
+ $questions = $questionnaire->questions();
+ $question = reset($questions);
$vals = [];
$i = 1;
foreach ($question->choices as $cid => $choice) {
$vals[$cid] = $i;
- $vals['q' . $question->id . '_' . $cid] = $i++;
+ $vals['q' . $question->id() . '_' . $cid] = $i++;
}
$response = $generator->create_question_response($questionnaire, $question, $vals, $userid);
// Test the responses for this questionnaire.
- $this->response_tests($questionnaire->id, $response->id, $userid);
+ $this->response_tests($questionnaire->id(), $response->id, $userid);
// Retrieve the specific rank response.
- $multresponses = $DB->get_records('questionnaire_response_rank', ['response_id' => $response->id]);
+ $multresponses = $DB->get_records('questionnaire_response_rank', ['responseid' => $response->id]);
$this->assertEquals(3, count($multresponses));
foreach ($multresponses as $multresponse) {
- $this->assertEquals($question->id, $multresponse->question_id);
- $this->assertEquals($vals[$multresponse->choice_id], $multresponse->rankvalue);
+ $this->assertEquals($question->id(), $multresponse->questionid);
+ $this->assertEquals($vals[$multresponse->choiceid], $multresponse->rankvalue);
}
}
@@ -389,17 +395,14 @@ public function create_test_questionnaire($qtype, $questiondata = [], $choicedat
$course = $this->getDataGenerator()->create_course();
$generator = $this->getDataGenerator()->get_plugin_generator('mod_questionnaire');
$questionnaire = $generator->create_instance(['course' => $course->id]);
- $cm = get_coursemodule_from_instance('questionnaire', $questionnaire->id);
- $questiondata['type_id'] = $qtype;
- $questiondata['surveyid'] = $questionnaire->sid;
+ $questiondata['typeid'] = $qtype;
+ $questiondata['surveyid'] = $questionnaire->surveyid();
$questiondata['name'] = isset($questiondata['name']) ? $questiondata['name'] : 'Q1';
$questiondata['content'] = isset($questiondata['content']) ? $questiondata['content'] : 'Test content';
$generator->create_question($questionnaire, $questiondata, $choicedata);
- $questionnaire = new \questionnaire($course, $cm, $questionnaire->id, null, true);
-
- return $questionnaire;
+ return \mod_questionnaire\questionnaire::from_instanceid($questionnaire->id());
}
/**
@@ -434,7 +437,7 @@ private function response_tests(
/**
* Tests that an old boolean response is deleted correctly.
*
- * @covers \mod_questionnaire\responsetype\boolean
+ * @covers \mod_questionnaire\local\response\boolean
*/
public function test_create_old_response_boolean(): void {
global $DB;
@@ -449,22 +452,24 @@ public function test_create_old_response_boolean(): void {
$generator = $this->getDataGenerator()->get_plugin_generator('mod_questionnaire');
// Add a questionnaire that will delete old responses after one month.
$questionnaire1 = $generator->create_test_questionnaire($course, QUESYESNO, ['content' => 'Enter yes or no']);
- $question1 = reset($questionnaire1->questions);
+ $questions1 = $questionnaire1->questions();
+ $question1 = reset($questions1);
$response1 = $generator->create_question_response($questionnaire1, $question1, 'y', $userid);
$questionnaire2 = $generator->create_test_questionnaire($course, QUESYESNO, ['content' => 'Enter yes or no']);
- $question2 = reset($questionnaire2->questions);
+ $questions2 = $questionnaire2->questions();
+ $question2 = reset($questions2);
$response2 = $generator->create_question_response($questionnaire2, $question2, 'y', $userid);
- $this->response_tests($questionnaire1->id, $response1->id, $userid);
- $this->response_tests($questionnaire2->id, $response2->id, $userid);
+ $this->response_tests($questionnaire1->id(), $response1->id, $userid);
+ $this->response_tests($questionnaire2->id(), $response2->id, $userid);
// Set the removeafterfield for questionnaires.
$newquestionairre1 = new \stdClass();
- $newquestionairre1->id = $questionnaire1->id;
+ $newquestionairre1->id = $questionnaire1->id();
$newquestionairre1->removeafter = 2592000;
$newquestionairre2 = new \stdClass();
- $newquestionairre2->id = $questionnaire2->id;
+ $newquestionairre2->id = $questionnaire2->id();
$newquestionairre2->removeafter = 2592000;
$DB->update_record('questionnaire', $newquestionairre1);
$DB->update_record('questionnaire', $newquestionairre2);
@@ -476,13 +481,13 @@ public function test_create_old_response_boolean(): void {
$booleanresponses2->submitted = $booleanresponses2->submitted - 2592000 - 86400;
$DB->update_record('questionnaire_response', $booleanresponses1);
$DB->update_record('questionnaire_response', $booleanresponses2);
- questionnaire_delete_old_responses();
+ \mod_questionnaire\local\response\questionnaire_responses::delete_old_responses();
$responseresult1 = $DB->record_exists('questionnaire_response', ['id' => $response1->id]);
$responseresult2 = $DB->record_exists('questionnaire_response', ['id' => $response2->id]);
$this->assertEmpty($responseresult1);
$this->assertEmpty($responseresult2);
- $boolresponseresult1 = $DB->record_exists('questionnaire_response_bool', ['response_id' => $response1->id]);
- $boolresponseresult2 = $DB->record_exists('questionnaire_response_bool', ['response_id' => $response2->id]);
+ $boolresponseresult1 = $DB->record_exists('questionnaire_response_bool', ['responseid' => $response1->id]);
+ $boolresponseresult2 = $DB->record_exists('questionnaire_response_bool', ['responseid' => $response2->id]);
$this->assertEmpty($boolresponseresult1);
$this->assertEmpty($boolresponseresult2);
}
diff --git a/tests/save_mobile_data_test.php b/tests/save_mobile_data_test.php
new file mode 100644
index 000000000..646d1d21b
--- /dev/null
+++ b/tests/save_mobile_data_test.php
@@ -0,0 +1,247 @@
+.
+
+/**
+ * Unit tests for questionnaire::save_mobile_data().
+ *
+ * @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;
+
+defined('MOODLE_INTERNAL') || die();
+
+global $CFG;
+
+/**
+ * Tests for questionnaire::save_mobile_data().
+ *
+ * @group mod_questionnaire
+ * @covers \mod_questionnaire\questionnaire::save_mobile_data
+ */
+final class save_mobile_data_test extends \advanced_testcase {
+ // Helpers.
+
+ /**
+ * Create a course, questionnaire instance, and one optional-text question.
+ * Returns [$questionnaire, $userid].
+ *
+ * The pagebreak requires at least two questions before it (position >= 2) so that
+ * load_questions() does not treat it as a leading break and skip it. We therefore
+ * add Q1 + Q2 on page 1, then a pagebreak, then Q3 on page 2.
+ *
+ * @param bool $addpagebreak Also add a page break + a third text question (for nav tests).
+ * @param bool $required Make the first text question required.
+ * @return array [$questionnaire, $userid]
+ */
+ private function make_fixture(bool $addpagebreak = false, bool $required = false): array {
+ $this->resetAfterTest();
+ $this->setAdminUser();
+
+ global $DB;
+ $generator = $this->getDataGenerator();
+ $course = $generator->create_course();
+ $qgen = $generator->get_plugin_generator('mod_questionnaire');
+ $questionnaire = $qgen->create_instance(['course' => $course->id]);
+
+ $surveyid = $questionnaire->surveyid();
+ $qgen->create_question($questionnaire, [
+ 'surveyid' => $surveyid,
+ 'name' => 'Q1',
+ 'typeid' => QUESTEXT,
+ 'content' => 'Enter some text',
+ 'required' => $required ? 'y' : 'n',
+ ]);
+
+ if ($addpagebreak) {
+ // Two questions on page 1 push the pagebreak to position >= 2, which is
+ // required for load_questions() to recognise it as a real section break.
+ $qgen->create_question($questionnaire, [
+ 'surveyid' => $surveyid,
+ 'name' => 'Q2',
+ 'typeid' => QUESTEXT,
+ 'content' => 'Enter more text',
+ 'required' => 'n',
+ ]);
+ $qgen->create_question($questionnaire, [
+ 'surveyid' => $surveyid,
+ 'name' => 'break1',
+ 'typeid' => QUESPAGEBREAK,
+ 'content' => '',
+ ]);
+ $qgen->create_question($questionnaire, [
+ 'surveyid' => $surveyid,
+ 'name' => 'Q3',
+ 'typeid' => QUESTEXT,
+ 'content' => 'Enter final text',
+ 'required' => 'n',
+ ]);
+ }
+
+ $user = $generator->create_user();
+ $studentrole = $DB->get_record('role', ['shortname' => 'student'], '*', MUST_EXIST);
+ $generator->enrol_user($user->id, $course->id, $studentrole->id);
+
+ // Reload so questions_by_section is populated.
+ $questionnaire = questionnaire::from_instanceid($questionnaire->id());
+
+ return [$questionnaire, $user->id];
+ }
+
+ // Tests for action = '' (plain save).
+
+ /**
+ * A plain save with no action inserts a response record and returns an empty array.
+ *
+ * @covers \mod_questionnaire\questionnaire::save_mobile_data
+ */
+ public function test_plain_save_inserts_response(): void {
+ global $DB;
+ [$questionnaire, $userid] = $this->make_fixture();
+
+ $result = $questionnaire->save_mobile_data($userid, 1, 0, 0, 0, '', []);
+
+ $this->assertIsArray($result);
+ $this->assertArrayNotHasKey('warnings', $result);
+ $this->assertTrue(
+ $DB->record_exists('questionnaire_response', ['questionnaireid' => $questionnaire->id(), 'userid' => $userid])
+ );
+ }
+
+ /**
+ * When completed=1 (reviewing), a plain save must NOT insert a new response.
+ *
+ * @covers \mod_questionnaire\questionnaire::save_mobile_data
+ */
+ public function test_plain_save_skips_insert_when_completed(): void {
+ global $DB;
+ [$questionnaire, $userid] = $this->make_fixture();
+
+ $result = $questionnaire->save_mobile_data($userid, 1, 1, 0, 0, '', []);
+
+ $this->assertIsArray($result);
+ $this->assertArrayNotHasKey('warnings', $result);
+ $this->assertFalse(
+ $DB->record_exists('questionnaire_response', ['questionnaireid' => $questionnaire->id(), 'userid' => $userid])
+ );
+ }
+
+ /**
+ * A required question with no answer returns warnings and a response object; no DB record is created.
+ *
+ * @covers \mod_questionnaire\questionnaire::save_mobile_data
+ */
+ public function test_plain_save_returns_warnings_for_missing_required(): void {
+ global $DB;
+ [$questionnaire, $userid] = $this->make_fixture(false, true); // Required question.
+
+ $result = $questionnaire->save_mobile_data($userid, 1, 0, 0, 0, '', []);
+
+ $this->assertArrayHasKey('warnings', $result);
+ $this->assertNotEmpty($result['warnings']);
+ $this->assertArrayHasKey('response', $result);
+ $this->assertFalse(
+ $DB->record_exists('questionnaire_response', ['questionnaireid' => $questionnaire->id(), 'userid' => $userid])
+ );
+ }
+
+ // Tests for submit=1.
+
+ /**
+ * When submit=1 and there are no warnings, the response is committed (complete='y').
+ *
+ * @covers \mod_questionnaire\questionnaire::save_mobile_data
+ */
+ public function test_submit_commits_response(): void {
+ global $DB;
+ [$questionnaire, $userid] = $this->make_fixture();
+
+ $questionnaire->save_mobile_data($userid, 1, 0, 0, 1, '', []);
+
+ $record = $DB->get_record(
+ 'questionnaire_response',
+ ['questionnaireid' => $questionnaire->id(), 'userid' => $userid],
+ 'complete',
+ MUST_EXIST
+ );
+ $this->assertEquals('y', $record->complete);
+ }
+
+ /**
+ * When submit=1 but there are warnings (required question unanswered), the response is NOT committed.
+ *
+ * @covers \mod_questionnaire\questionnaire::save_mobile_data
+ */
+ public function test_submit_skipped_when_warnings_present(): void {
+ global $DB;
+ [$questionnaire, $userid] = $this->make_fixture(false, true); // Required question.
+
+ $questionnaire->save_mobile_data($userid, 1, 0, 0, 1, '', []);
+
+ $this->assertFalse(
+ $DB->record_exists('questionnaire_response', ['questionnaireid' => $questionnaire->id(), 'userid' => $userid])
+ );
+ }
+
+ // Tests for action = 'nextpage'.
+
+ /**
+ * nextpage action on a two-page questionnaire returns nextpagenum = 2.
+ *
+ * @covers \mod_questionnaire\questionnaire::save_mobile_data
+ */
+ public function test_nextpage_returns_next_section_number(): void {
+ [$questionnaire, $userid] = $this->make_fixture(true); // Two-page questionnaire.
+
+ $result = $questionnaire->save_mobile_data($userid, 1, 0, 0, 0, 'nextpage', []);
+
+ $this->assertArrayHasKey('nextpagenum', $result);
+ $this->assertEquals(2, $result['nextpagenum']);
+ }
+
+ // Tests for action = 'previouspage'.
+
+ /**
+ * previouspage action from page 2 returns nextpagenum = 1.
+ *
+ * @covers \mod_questionnaire\questionnaire::save_mobile_data
+ */
+ public function test_previouspage_returns_previous_section_number(): void {
+ [$questionnaire, $userid] = $this->make_fixture(true); // Two-page questionnaire.
+
+ $result = $questionnaire->save_mobile_data($userid, 2, 0, 0, 0, 'previouspage', []);
+
+ $this->assertArrayHasKey('nextpagenum', $result);
+ $this->assertEquals(1, $result['nextpagenum']);
+ }
+
+ /**
+ * previouspage action from page 1 returns nextpagenum = false (no previous page).
+ *
+ * @covers \mod_questionnaire\questionnaire::save_mobile_data
+ */
+ public function test_previouspage_returns_false_on_first_page(): void {
+ [$questionnaire, $userid] = $this->make_fixture(true);
+
+ $result = $questionnaire->save_mobile_data($userid, 1, 0, 0, 0, 'previouspage', []);
+
+ $this->assertArrayHasKey('nextpagenum', $result);
+ $this->assertFalse($result['nextpagenum']);
+ }
+}
diff --git a/tests/submission_controller_test.php b/tests/submission_controller_test.php
new file mode 100644
index 000000000..75dedb845
--- /dev/null
+++ b/tests/submission_controller_test.php
@@ -0,0 +1,195 @@
+.
+
+/**
+ * Unit tests for mod_questionnaire\submission_controller.
+ *
+ * @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;
+
+use mod_questionnaire\local\db\questionnaire_record;
+use mod_questionnaire\local\db\survey_record;
+
+defined('MOODLE_INTERNAL') || die();
+
+global $CFG;
+require_once($CFG->dirroot . '/mod/questionnaire/tests/questionnaire_testable.php');
+
+/**
+ * Unit tests for mod_questionnaire\submission_controller.
+ *
+ * @group mod_questionnaire
+ * @covers \mod_questionnaire\submission_controller
+ */
+final class submission_controller_test extends \advanced_testcase {
+ /**
+ * Build a questionnaire_record persistent populated from an in-memory object.
+ *
+ * @param array $fields
+ * @return questionnaire_record
+ */
+ private function make_module_record(array $fields = []): questionnaire_record {
+ return new questionnaire_record(0, (object)array_merge([
+ 'id' => 1,
+ 'course' => 1,
+ 'name' => 'Test questionnaire',
+ 'intro' => '',
+ 'introformat' => FORMAT_HTML,
+ 'qtype' => questionnaire::QTYPE_UNLIMITED,
+ 'respondenttype' => 'fullname',
+ 'respeligible' => 'all',
+ 'respview' => questionnaire::RESPVIEW_NEVER,
+ 'notifications' => 0,
+ 'opendate' => 0,
+ 'closedate' => 0,
+ 'resume' => 0,
+ 'navigate' => 0,
+ 'grade' => 0,
+ 'sid' => 1,
+ 'timemodified' => 0,
+ 'completionsubmit' => 0,
+ 'autonum' => 3,
+ 'progressbar' => 0,
+ 'removeafter' => 0,
+ ], $fields));
+ }
+
+ /**
+ * Build a survey_record persistent from an in-memory object.
+ *
+ * @param array $fields
+ * @return survey_record
+ */
+ private function make_survey_record(array $fields = []): survey_record {
+ return new survey_record(0, (object)array_merge([
+ 'id' => 1,
+ 'name' => 'Test survey',
+ 'courseid' => 1,
+ 'realm' => 'private',
+ 'status' => 0,
+ 'title' => 'Title',
+ 'email' => null,
+ 'subtitle' => null,
+ 'info' => null,
+ 'theme' => null,
+ 'thankspage' => null,
+ 'thankhead' => null,
+ 'thankbody' => null,
+ 'feedbacksections' => 0,
+ 'feedbacknotes' => null,
+ 'feedbackscores' => 0,
+ 'charttype' => null,
+ ], $fields));
+ }
+
+ /**
+ * Build a questionnaire_testable wrapping fresh module/survey records.
+ *
+ * @param array $modulefields
+ * @param array $surveyfields
+ * @return questionnaire_testable
+ */
+ private function make_questionnaire(array $modulefields = [], array $surveyfields = []): questionnaire_testable {
+ if (!array_key_exists('id', $surveyfields)) {
+ $surveyfields['id'] = $modulefields['sid'] ?? 1;
+ }
+ return new questionnaire_testable(
+ $this->make_module_record($modulefields),
+ $this->make_survey_record($surveyfields)
+ );
+ }
+
+ // Tests for existing_response_action().
+
+ /**
+ * Asserts existing_response_action() inserts a new DB record when rid is 0.
+ */
+ public function test_existing_response_action_inserts_when_rid_zero(): void {
+ global $DB;
+ $this->resetAfterTest();
+ $qid = 55561;
+ $q = $this->make_questionnaire(['id' => $qid]);
+ $q->set_questions_by_sec([1 => []]);
+ $newrid = $q->submission()->existing_response_action((object)['rid' => 0, 'sec' => 1], 1);
+ $this->assertGreaterThan(0, $newrid);
+ $this->assertTrue($DB->record_exists('questionnaire_response', ['id' => $newrid, 'questionnaireid' => $qid]));
+ }
+
+ /**
+ * Asserts existing_response_action() updates the submitted time on an existing record.
+ */
+ public function test_existing_response_action_updates_existing_record(): void {
+ global $DB;
+ $this->resetAfterTest();
+ $qid = 55562;
+ $q = $this->make_questionnaire(['id' => $qid]);
+ $q->set_questions_by_sec([1 => []]);
+ $originaltime = time() - 3600;
+ $rid = $DB->insert_record('questionnaire_response', (object)[
+ 'questionnaireid' => $qid, 'userid' => 1,
+ 'submitted' => $originaltime, 'complete' => 'n', 'grade' => 0,
+ ]);
+ $returnedrid = $q->submission()->existing_response_action((object)['rid' => $rid, 'sec' => 1], 1);
+ $this->assertSame((int)$rid, (int)$returnedrid);
+ $this->assertGreaterThan($originaltime, $DB->get_field('questionnaire_response', 'submitted', ['id' => $rid]));
+ }
+
+ // Tests for next_page_action().
+
+ /**
+ * Asserts next_page_action() returns the next section number when the response is valid.
+ */
+ public function test_next_page_action_returns_next_section_when_valid(): void {
+ $this->resetAfterTest();
+ // With no questions, response_check_format returns '' so the action proceeds.
+ $q = $this->make_questionnaire(['id' => 44451, 'navigate' => 0]);
+ $q->set_questions_by_sec([1 => []]);
+ $result = $q->submission()->next_page_action((object)['rid' => 0, 'sec' => 1], 1);
+ // With no dependencies, next_page(1, 0) increments to 2.
+ $this->assertSame(2, $result);
+ }
+
+ // Tests for previous_page_action().
+
+ /**
+ * Asserts previous_page_action() saves the response and returns the previous section number.
+ */
+ public function test_previous_page_action_returns_previous_section(): void {
+ $this->resetAfterTest();
+ $q = $this->make_questionnaire(['id' => 44452, 'navigate' => 0]);
+ $q->set_questions_by_sec([1 => [], 2 => []]);
+ $result = $q->submission()->previous_page_action((object)['rid' => 0, 'sec' => 2], 1);
+ // With no dependencies, prev_page(2, 0) returns 1.
+ $this->assertSame(1, $result);
+ }
+
+ /**
+ * Asserts previous_page_action() returns false when already on the first section.
+ */
+ public function test_previous_page_action_returns_false_on_first_section(): void {
+ $this->resetAfterTest();
+ $q = $this->make_questionnaire(['id' => 44453, 'navigate' => 0]);
+ $q->set_questions_by_sec([1 => []]);
+ $result = $q->submission()->previous_page_action((object)['rid' => 0, 'sec' => 1], 1);
+ // Prev_page(1, 0) decrements to 0, which returns false.
+ $this->assertFalse($result);
+ }
+}
diff --git a/tests/submission_notifier_test.php b/tests/submission_notifier_test.php
new file mode 100644
index 000000000..4303f33ca
--- /dev/null
+++ b/tests/submission_notifier_test.php
@@ -0,0 +1,207 @@
+.
+
+/**
+ * Unit tests for mod_questionnaire\submission_notifier.
+ *
+ * @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;
+
+/**
+ * Unit tests for mod_questionnaire\submission_notifier.
+ *
+ * @group mod_questionnaire
+ * @covers \mod_questionnaire\submission_notifier
+ */
+final class submission_notifier_test extends \advanced_testcase {
+ /**
+ * Create a course, a questionnaire instance, and an enrolled student. Return both the
+ * questionnaire domain object and the student so callers can wire up further state.
+ *
+ * @param array $instanceopts Extra options to pass to create_instance().
+ * @return array [questionnaire, student]
+ */
+ private function setup_with_student(array $instanceopts = []): array {
+ global $DB;
+ $generator = $this->getDataGenerator();
+ $course = $generator->create_course();
+ $instance = $generator->get_plugin_generator('mod_questionnaire')
+ ->create_instance(array_merge(['course' => $course->id], $instanceopts));
+
+ $student = $generator->create_user();
+ $studentrole = $DB->get_record('role', ['shortname' => 'student'], '*', MUST_EXIST);
+ $generator->enrol_user($student->id, $course->id, $studentrole->id);
+
+ return [$instance, $student];
+ }
+
+ /**
+ * Insert a complete response and return its id.
+ *
+ * @param int $questionnaireid
+ * @param int $userid
+ * @return int
+ */
+ private function insert_response(int $questionnaireid, int $userid): int {
+ global $DB;
+ return $DB->insert_record('questionnaire_response', (object)[
+ 'questionnaireid' => $questionnaireid,
+ 'userid' => $userid,
+ 'submitted' => time(),
+ 'complete' => 'y',
+ 'grade' => 0,
+ ]);
+ }
+
+ /**
+ * get_notifiable_users() returns every enrolled user with the capability except the submitter.
+ */
+ public function test_get_notifiable_users_excludes_submitter(): void {
+ $this->resetAfterTest();
+ $this->setAdminUser();
+ $generator = $this->getDataGenerator();
+
+ [$questionnaire, $student] = $this->setup_with_student();
+ // Enrol a teacher who will receive notifications.
+ $teacher = $generator->create_user();
+ $teacherrole = $generator->create_role();
+ assign_capability(
+ 'mod/questionnaire:submissionnotification',
+ CAP_ALLOW,
+ $teacherrole,
+ $questionnaire->context()->id
+ );
+ $generator->role_assign($teacherrole, $teacher->id, $questionnaire->context()->id);
+ $generator->enrol_user($teacher->id, $questionnaire->courseid());
+
+ $notifier = new submission_notifier($questionnaire);
+ $users = $notifier->get_notifiable_users($student->id);
+
+ $this->assertArrayHasKey($teacher->id, $users);
+ $this->assertArrayNotHasKey($student->id, $users);
+ }
+
+ /**
+ * get_notifiable_users() returns an empty array when nobody has the notification capability.
+ */
+ public function test_get_notifiable_users_empty_when_nobody_capable(): void {
+ $this->resetAfterTest();
+ [$questionnaire, $student] = $this->setup_with_student();
+ // The student does not have mod/questionnaire:submissionnotification by default.
+
+ $notifier = new submission_notifier($questionnaire);
+ $this->assertSame([], $notifier->get_notifiable_users($student->id));
+ }
+
+ /**
+ * notify() does nothing when the survey has no email recipient and notifications are off.
+ */
+ public function test_notify_noop_with_no_recipients(): void {
+ $this->resetAfterTest();
+ $this->setAdminUser();
+
+ [$questionnaire, $student] = $this->setup_with_student();
+ $rid = $this->insert_response($questionnaire->id(), $student->id);
+
+ $emailsink = $this->redirectEmails();
+ $messagesink = $this->redirectMessages();
+
+ $notifier = new submission_notifier($questionnaire);
+ $this->assertTrue($notifier->notify($rid));
+
+ $this->assertCount(0, $emailsink->get_messages());
+ $this->assertCount(0, $messagesink->get_messages());
+ $emailsink->close();
+ $messagesink->close();
+ }
+
+ /**
+ * notify() sends an email to every address in the survey's configured recipient list.
+ */
+ public function test_notify_sends_email_to_survey_recipients(): void {
+ global $DB;
+ $this->resetAfterTest();
+ $this->setAdminUser();
+
+ [$questionnaire, $student] = $this->setup_with_student();
+ // Add an email recipient list to the survey row.
+ $DB->set_field(
+ 'questionnaire_survey',
+ 'email',
+ 'first@example.com,second@example.com',
+ ['id' => $questionnaire->surveyid()]
+ );
+
+ $rid = $this->insert_response($questionnaire->id(), $student->id);
+
+ $emailsink = $this->redirectEmails();
+
+ // Reload the questionnaire so the survey object picks up the email.
+ $reloaded = questionnaire::from_instanceid($questionnaire->id());
+ (new submission_notifier($reloaded))->notify($rid);
+
+ $emails = $emailsink->get_messages();
+ $this->assertCount(2, $emails);
+ $addresses = array_map(fn($m) => $m->to, $emails);
+ $this->assertContains('first@example.com', $addresses);
+ $this->assertContains('second@example.com', $addresses);
+ $emailsink->close();
+ }
+
+ /**
+ * notify() dispatches a Moodle message to capable users when notifications are enabled.
+ */
+ public function test_notify_sends_messages_when_notifications_enabled(): void {
+ global $DB;
+ $this->resetAfterTest();
+ $generator = $this->getDataGenerator();
+
+ [$questionnaire, $student] = $this->setup_with_student();
+ // Enable notifications mode 1 (subject-only message).
+ $DB->set_field('questionnaire', 'notifications', 1, ['id' => $questionnaire->id()]);
+
+ // Enrol a teacher with the notification capability.
+ $teacher = $generator->create_user();
+ $teacherrole = $generator->create_role();
+ assign_capability(
+ 'mod/questionnaire:submissionnotification',
+ CAP_ALLOW,
+ $teacherrole,
+ $questionnaire->context()->id
+ );
+ $generator->role_assign($teacherrole, $teacher->id, $questionnaire->context()->id);
+ $generator->enrol_user($teacher->id, $questionnaire->courseid());
+
+ $rid = $this->insert_response($questionnaire->id(), $student->id);
+
+ // The notifier reads $USER->id as the submitter.
+ $this->setUser($student);
+ $messagesink = $this->redirectMessages();
+
+ $reloaded = questionnaire::from_instanceid($questionnaire->id());
+ (new submission_notifier($reloaded))->notify($rid);
+
+ $messages = $messagesink->get_messages();
+ $this->assertCount(1, $messages);
+ $this->assertEquals($teacher->id, $messages[0]->useridto);
+ $messagesink->close();
+ }
+}
diff --git a/tests/survey_settings_controller_test.php b/tests/survey_settings_controller_test.php
new file mode 100644
index 000000000..83364866d
--- /dev/null
+++ b/tests/survey_settings_controller_test.php
@@ -0,0 +1,126 @@
+.
+
+/**
+ * Unit tests for mod_questionnaire\survey_settings_controller.
+ *
+ * @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;
+
+/**
+ * Unit tests for mod_questionnaire\survey_settings_controller.
+ *
+ * @group mod_questionnaire
+ * @covers \mod_questionnaire\survey_settings_controller
+ */
+final class survey_settings_controller_test extends \advanced_testcase {
+ /**
+ * Build a course + questionnaire fixture.
+ *
+ * @return questionnaire
+ */
+ private function setup_questionnaire(): questionnaire {
+ $this->resetAfterTest();
+ $this->setAdminUser();
+ $course = $this->getDataGenerator()->create_course();
+ return $this->getDataGenerator()
+ ->get_plugin_generator('mod_questionnaire')
+ ->create_instance(['course' => $course->id]);
+ }
+
+ /**
+ * Build the minimal formdata stdClass that the qsettings form returns from get_data().
+ *
+ * @param questionnaire $questionnaire
+ * @param array $overrides
+ * @return \stdClass
+ */
+ private function formdata(questionnaire $questionnaire, array $overrides = []): \stdClass {
+ $defaults = [
+ 'name' => $questionnaire->survey()->name(),
+ 'realm' => $questionnaire->survey()->realm(),
+ 'title' => $questionnaire->survey()->title(),
+ 'subtitle' => 'Sub',
+ 'info' => ['itemid' => 0, 'format' => FORMAT_HTML, 'text' => 'About this survey'],
+ 'thankspage' => 'https://example.org/done',
+ 'thankhead' => 'Thanks!',
+ 'thankbody' => ['itemid' => 0, 'format' => FORMAT_HTML, 'text' => 'Bye'],
+ 'email' => 'alerts@example.org',
+ 'courseid' => $questionnaire->course()->id,
+ ];
+ return (object) array_merge($defaults, $overrides);
+ }
+
+ /**
+ * Asserts save() persists each settings field onto the survey row.
+ */
+ public function test_save_persists_form_fields(): void {
+ global $DB;
+
+ $questionnaire = $this->setup_questionnaire();
+ $result = (new survey_settings_controller($questionnaire))->save($this->formdata($questionnaire, [
+ 'subtitle' => 'New subtitle',
+ 'thankhead' => 'New thank head',
+ 'email' => 'sup@example.org',
+ ]));
+
+ $this->assertSame($questionnaire->surveyid(), $result);
+ $row = $DB->get_record('questionnaire_survey', ['id' => $questionnaire->surveyid()]);
+ $this->assertEquals('New subtitle', $row->subtitle);
+ $this->assertEquals('New thank head', $row->thankhead);
+ $this->assertEquals('sup@example.org', $row->email);
+ $this->assertStringContainsString('About this survey', $row->info);
+ $this->assertStringContainsString('Bye', $row->thankbody);
+ }
+
+ /**
+ * Asserts save() throws when underlying update rejects (e.g. duplicate name).
+ */
+ public function test_save_throws_when_update_rejected(): void {
+ $questionnaire = $this->setup_questionnaire();
+
+ // Empty title is rejected by survey::update_settings().
+ $this->expectException(\moodle_exception::class);
+ (new survey_settings_controller($questionnaire))->save($this->formdata($questionnaire, [
+ 'title' => '',
+ ]));
+ }
+
+ /**
+ * Asserts save() leaves the survey row id untouched even when the form would otherwise
+ * carry a misleading 'id' value. Hardened against the historical feedback.php bug.
+ */
+ public function test_save_does_not_corrupt_survey_id(): void {
+ global $DB;
+
+ $questionnaire = $this->setup_questionnaire();
+ $surveyid = $questionnaire->surveyid();
+
+ $formdata = $this->formdata($questionnaire);
+ // Smuggle a stray id like the legacy form payload used to. survey_settings_controller
+ // builds an explicit allowlist, so this must be ignored.
+ $formdata->id = 999999;
+
+ (new survey_settings_controller($questionnaire))->save($formdata);
+
+ $this->assertTrue($DB->record_exists('questionnaire_survey', ['id' => $surveyid]));
+ }
+}
diff --git a/version.php b/version.php
index 0d5163bdd..89846aa64 100644
--- a/version.php
+++ b/version.php
@@ -25,10 +25,10 @@
defined('MOODLE_INTERNAL') || die();
-$plugin->version = 2025041402;
-$plugin->requires = 2025041400.00; // Moodle 5.0 and up.
+$plugin->version = 2026050100.02;
+$plugin->requires = 2024100712.02; // Moodle 5.0 and up.
$plugin->component = 'mod_questionnaire';
-$plugin->release = '5.0.3 (Build - 2026052200)';
-$plugin->maturity = MATURITY_STABLE;
+$plugin->release = '5.2.0 (Build - 2026041400)';
+$plugin->maturity = MATURITY_ALPHA;
diff --git a/view.php b/view.php
index 687ef0cb6..baace19a3 100644
--- a/view.php
+++ b/view.php
@@ -25,146 +25,137 @@
*/
require_once("../../config.php");
-require_once($CFG->dirroot . '/mod/questionnaire/locallib.php');
require_once($CFG->libdir . '/completionlib.php');
-require_once($CFG->dirroot . '/mod/questionnaire/questionnaire.class.php');
-if (!isset($SESSION->questionnaire)) {
- $SESSION->questionnaire = new stdClass();
-}
-$SESSION->questionnaire->current_tab = 'view';
-
-$id = optional_param('id', null, PARAM_INT); // Course Module ID.
-$a = optional_param('a', null, PARAM_INT); // Or questionnaire ID.
+use mod_questionnaire\questionnaire;
+use mod_questionnaire\output\viewpage;
+$cmid = optional_param('id', null, PARAM_INT); // Course Module ID.
+$qid = optional_param('a', null, PARAM_INT); // Or questionnaire ID.
$sid = optional_param('sid', null, PARAM_INT); // Survey id.
-
-[$cm, $course, $questionnaire] = questionnaire_get_standard_page_items($id, $a);
+if (!empty($cmid)) {
+ $questionnaire = questionnaire::from_cmid($cmid);
+} else {
+ $questionnaire = questionnaire::from_instanceid($qid);
+}
// Check login and get context.
-require_course_login($course, true, $cm);
-$context = context_module::instance($cm->id);
+require_course_login($questionnaire->courseid(), true, $questionnaire->coursemodule());
$url = new moodle_url($CFG->wwwroot . '/mod/questionnaire/view.php');
-if (isset($id)) {
- $url->param('id', $id);
+if (isset($cmid)) {
+ $url->param('id', $cmid);
} else {
- $url->param('a', $a);
+ $url->param('a', $qid);
}
if (isset($sid)) {
$url->param('sid', $sid);
}
+// Log this course module view.
+$anonymous = $questionnaire->is_anonymous();
+$event = \mod_questionnaire\event\course_module_viewed::create(
+ [
+ 'objectid' => $questionnaire->id(),
+ 'anonymous' => $anonymous,
+ 'context' => $questionnaire->context(),
+ ]
+);
+$event->trigger();
+
$PAGE->set_url($url);
-$PAGE->set_context($context);
-$questionnaire = new questionnaire($course, $cm, 0, $questionnaire);
-// Add renderer and page objects to the questionnaire object for display use.
-$questionnaire->add_renderer($PAGE->get_renderer('mod_questionnaire'));
-$questionnaire->add_page(new \mod_questionnaire\output\viewpage());
+$PAGE->set_context($questionnaire->context());
+$PAGE->set_title(format_string($questionnaire->name()));
+$PAGE->set_heading(format_string($questionnaire->course()->fullname));
-$PAGE->set_title(format_string($questionnaire->name));
-$PAGE->set_heading(format_string($course->fullname));
+$renderer = $PAGE->get_renderer('mod_questionnaire');
+$page = new viewpage();
-echo $questionnaire->renderer->header();
// No need to print out intro or name in Moodle 4 and above.
-
-$cm = $questionnaire->cm;
+$cm = $questionnaire->coursemodule();
$currentgroupid = groups_get_activity_group($cm);
if (!groups_is_member($currentgroupid, $USER->id)) {
$currentgroupid = 0;
}
$message = $questionnaire->user_access_messages($USER->id);
-if ($message !== false) {
- $questionnaire->page->add_to_page('message', $message);
-} else if ($questionnaire->user_can_take($USER->id)) {
- if ($questionnaire->questions) { // Sanity check.
+if ($message !== null) {
+ $page->add_to_page('message', $message);
+} else if ($questionnaire->capabilities()->user_can_take($USER->id)) {
+ if ($questionnaire->questions()) { // Sanity check.
if (!$questionnaire->user_has_saved_response($USER->id)) {
- $questionnaire->page->add_to_page(
+ $page->add_to_page(
'complete',
'' . get_string('answerquestions', 'questionnaire') . ' '
);
} else {
$resumesurvey = get_string('resumesurvey', 'questionnaire');
- $questionnaire->page->add_to_page(
+ $page->add_to_page(
'complete',
'' . $resumesurvey . ' '
);
}
} else {
- $questionnaire->page->add_to_page('message', get_string('noneinuse', 'questionnaire'));
+ $page->add_to_page('message', get_string('noneinuse', 'questionnaire'));
}
}
-if ($questionnaire->capabilities->editquestions && !$questionnaire->questions && $questionnaire->is_active()) {
- $questionnaire->page->add_to_page(
+if ($questionnaire->capabilities()->can_edit_questions() && !$questionnaire->questions() && $questionnaire->is_active()) {
+ $page->add_to_page(
'complete',
'' .
+ 'id=' . $questionnaire->coursemodule()->id) . '" class="btn btn-primary">' .
get_string('addquestions', 'questionnaire') . ' '
);
}
// Time zone message (if required).
-if (!$message && $questionnaire->is_open() && !$questionnaire->is_closed()) {
+if ($message === null && $questionnaire->is_open() && !$questionnaire->is_closed()) {
$info = $questionnaire->view_information();
- $questionnaire->page->add_to_page('info', $questionnaire->access_messages($info));
+ $page->add_to_page('info', $questionnaire->access_messages($info));
}
if (isguestuser()) {
$guestno = html_writer::tag('p', get_string('noteligible', 'questionnaire'));
$liketologin = html_writer::tag('p', get_string('liketologin'));
- $questionnaire->page->add_to_page(
+ $page->add_to_page(
'guestuser',
- $questionnaire->renderer->confirm($guestno . "\n\n" . $liketologin . "\n", get_login_url(), get_local_referer(false))
+ $renderer->confirm($guestno . "\n\n" . $liketologin . "\n", get_login_url(), get_local_referer(false))
);
}
-// Log this course module view.
-// Needed for the event logging.
-$context = context_module::instance($questionnaire->cm->id);
-$anonymous = $questionnaire->respondenttype == 'anonymous';
-
-$event = \mod_questionnaire\event\course_module_viewed::create(
- [
- 'objectid' => $questionnaire->id,
- 'anonymous' => $anonymous,
- 'context' => $context,
- ]
-);
-$event->trigger();
-
$usernumresp = $questionnaire->count_submissions($USER->id);
-if ($questionnaire->capabilities->readownresponses && ($usernumresp > 0)) {
- $argstr = 'instance=' . $questionnaire->id . '&user=' . $USER->id;
+if ($questionnaire->capabilities()->can_read_own_responses() && ($usernumresp > 0)) {
+ $argstr = 'instance=' . $questionnaire->id() . '&user=' . $USER->id;
if ($usernumresp > 1) {
$titletext = get_string('viewyourresponses', 'questionnaire', $usernumresp);
} else {
$titletext = get_string('yourresponse', 'questionnaire');
$argstr .= '&byresponse=1&action=vresp';
}
- $questionnaire->page->add_to_page(
+ $page->add_to_page(
'yourresponse',
'' . $titletext . ' '
);
}
-if ($questionnaire->can_view_all_responses($usernumresp)) {
- $argstr = 'instance=' . $questionnaire->id . '&group=' . $currentgroupid;
- $questionnaire->page->add_to_page(
+if ($questionnaire->capabilities()->can_view_all_responses($usernumresp)) {
+ $argstr = 'instance=' . $questionnaire->id() . '&group=' . $currentgroupid;
+ $page->add_to_page(
'allresponses',
'' . get_string('viewallresponses', 'questionnaire') . ' '
);
}
-echo $questionnaire->renderer->render($questionnaire->page);
-echo $questionnaire->renderer->footer();
+echo $renderer->header();
+echo $renderer->render($page);
+echo $renderer->footer();