diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bf0ce0bd9..3aafab55c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -76,6 +76,24 @@ jobs: IGNORE_PATHS: 'templates/local/mobile' PHPDOCCHECKER_IGNORE_PATHS: /^vendor/ + - name: Guard against $SESSION->questionnaire regressions + if: ${{ always() }} + working-directory: plugin + run: | + if git grep -nE '\$SESSION->questionnaire' -- ':!.github/**'; then + echo "::error::Found \$SESSION->questionnaire usage. State must round-trip via forms, URL params, persistent records, or computed-on-render — never via the session object. See [[project-session-removal-plan]]." + exit 1 + fi + + - name: Guard against survey feedback-accessor regressions + if: ${{ always() }} + working-directory: plugin + run: | + if git grep -nE 'survey\(\)->(feedbacknotes|feedbacksections|feedbackscores|charttype)\(' -- ':!.github/**'; then + echo "::error::Found a call to a survey feedback accessor that was dropped in Phase 4 of the feedback-domain-class extraction. Use \$q->feedback()->notes()/mode()/show_scores()/chart_type() instead. See [[project-feedback-domain-class-plan]]." + exit 1 + fi + - name: PHP Lint if: ${{ always() }} run: moodle-plugin-ci phplint diff --git a/amd/build/attempt_form.min.js b/amd/build/attempt_form.min.js new file mode 100644 index 000000000..d4dec74dd --- /dev/null +++ b/amd/build/attempt_form.min.js @@ -0,0 +1,12 @@ +/** + * Form-change watcher for the survey response form. + * + * Replaces the legacy M.mod_questionnaire.init_attempt_form callback. + * + * @module mod_questionnaire/attempt_form + * @copyright 2026 Mike Churchward (mike.churchward@poetopensource.org) + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +define("mod_questionnaire/attempt_form",["core_form/changechecker"],(function(FormChangeChecker){return{init:function(){FormChangeChecker.watchFormById("phpesp_response")}}})); + +//# sourceMappingURL=attempt_form.min.js.map \ No newline at end of file diff --git a/amd/build/attempt_form.min.js.map b/amd/build/attempt_form.min.js.map new file mode 100644 index 000000000..cf9f6daf9 --- /dev/null +++ b/amd/build/attempt_form.min.js.map @@ -0,0 +1 @@ +{"version":3,"file":"attempt_form.min.js","sources":["../src/attempt_form.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/**\n * Form-change watcher for the survey response form.\n *\n * Replaces the legacy M.mod_questionnaire.init_attempt_form callback.\n *\n * @module mod_questionnaire/attempt_form\n * @copyright 2026 Mike Churchward (mike.churchward@poetopensource.org)\n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\ndefine(['core_form/changechecker'], function(FormChangeChecker) {\n return {\n /**\n * Attach the form-change watcher to the response form.\n */\n init: function() {\n FormChangeChecker.watchFormById('phpesp_response');\n }\n };\n});\n"],"names":["define","FormChangeChecker","init","watchFormById"],"mappings":";;;;;;;;;AAwBAA,wCAAO,CAAC,4BAA4B,SAASC,yBAClC,CAIHC,KAAM,WACFD,kBAAkBE,cAAc"} \ No newline at end of file diff --git a/amd/build/chart.min.js b/amd/build/chart.min.js new file mode 100644 index 000000000..bc4a0b56a --- /dev/null +++ b/amd/build/chart.min.js @@ -0,0 +1,26 @@ +/** + * RGraph chart driver for the feedback scoreboard. + * + * Driven by a declarative spec built in PHP by + * mod_questionnaire\local\feedback\chart_renderer::render() and dispatched via + * $PAGE->requires->js_call_amd('mod_questionnaire/chart', 'render', [spec]). + * + * Each entry in spec.charts describes one canvas to draw: + * { + * canvasId: 'questionnaire-chart-1-primary', + * type: 'Bipolar' | 'VProgress' | 'HBar' | 'Radar' | 'Rose', + * args: [...] // additional constructor args after canvasId + * sets: [['chart.title', 'Your response'], ['chart.xmax', 100], ...] + * } + * + * The module assumes the corresponding RGraph third-party scripts have already + * been loaded into window.RGraph by the caller (chart_renderer queues these + * via $PAGE->requires->js() adjacent to the js_call_amd). + * + * @module mod_questionnaire/chart + * @copyright 2026 Mike Churchward (mike.churchward@poetopensource.org) + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +define("mod_questionnaire/chart",[],(function(){var drawOne=function(spec){if(void 0!==window.RGraph&&"function"==typeof window.RGraph[spec.type]&&document.getElementById(spec.canvasId)){var ctorArgs=[spec.canvasId].concat(spec.args||[]),chart=new(Function.prototype.bind.apply(window.RGraph[spec.type],[null].concat(ctorArgs)));(spec.sets||[]).forEach((function(pair){chart.Set(pair[0],pair[1])})),chart.Draw()}};return{render:function(spec){spec&&Array.isArray(spec.charts)&&spec.charts.forEach(drawOne)}}})); + +//# sourceMappingURL=chart.min.js.map \ No newline at end of file diff --git a/amd/build/chart.min.js.map b/amd/build/chart.min.js.map new file mode 100644 index 000000000..2bb85b682 --- /dev/null +++ b/amd/build/chart.min.js.map @@ -0,0 +1 @@ +{"version":3,"file":"chart.min.js","sources":["../src/chart.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/**\n * RGraph chart driver for the feedback scoreboard.\n *\n * Driven by a declarative spec built in PHP by\n * mod_questionnaire\\local\\feedback\\chart_renderer::render() and dispatched via\n * $PAGE->requires->js_call_amd('mod_questionnaire/chart', 'render', [spec]).\n *\n * Each entry in spec.charts describes one canvas to draw:\n * {\n * canvasId: 'questionnaire-chart-1-primary',\n * type: 'Bipolar' | 'VProgress' | 'HBar' | 'Radar' | 'Rose',\n * args: [...] // additional constructor args after canvasId\n * sets: [['chart.title', 'Your response'], ['chart.xmax', 100], ...]\n * }\n *\n * The module assumes the corresponding RGraph third-party scripts have already\n * been loaded into window.RGraph by the caller (chart_renderer queues these\n * via $PAGE->requires->js() adjacent to the js_call_amd).\n *\n * @module mod_questionnaire/chart\n * @copyright 2026 Mike Churchward (mike.churchward@poetopensource.org)\n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\ndefine([], function() {\n /**\n * Draw one chart from a spec entry.\n *\n * @param {{canvasId: string, type: string, args: Array, sets: Array<[string, *]>}} spec\n */\n var drawOne = function(spec) {\n if (typeof window.RGraph === 'undefined' || typeof window.RGraph[spec.type] !== 'function') {\n return;\n }\n var canvas = document.getElementById(spec.canvasId);\n if (!canvas) {\n return;\n }\n var ctorArgs = [spec.canvasId].concat(spec.args || []);\n var chart = new (Function.prototype.bind.apply(window.RGraph[spec.type], [null].concat(ctorArgs)))();\n (spec.sets || []).forEach(function(pair) {\n chart.Set(pair[0], pair[1]);\n });\n chart.Draw();\n };\n\n return {\n /**\n * Render every chart in the spec.\n *\n * @param {{charts: Array}} spec\n */\n render: function(spec) {\n if (!spec || !Array.isArray(spec.charts)) {\n return;\n }\n spec.charts.forEach(drawOne);\n }\n };\n});\n"],"names":["define","drawOne","spec","window","RGraph","type","document","getElementById","canvasId","ctorArgs","concat","args","chart","Function","prototype","bind","apply","sets","forEach","pair","Set","Draw","render","Array","isArray","charts"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;AAsCAA,iCAAO,IAAI,eAMHC,QAAU,SAASC,cACU,IAAlBC,OAAOC,QAA8D,mBAA7BD,OAAOC,OAAOF,KAAKG,OAGzDC,SAASC,eAAeL,KAAKM,eAItCC,SAAW,CAACP,KAAKM,UAAUE,OAAOR,KAAKS,MAAQ,IAC/CC,MAAQ,IAAKC,SAASC,UAAUC,KAAKC,MAAMb,OAAOC,OAAOF,KAAKG,MAAO,CAAC,MAAMK,OAAOD,aACtFP,KAAKe,MAAQ,IAAIC,SAAQ,SAASC,MAC/BP,MAAMQ,IAAID,KAAK,GAAIA,KAAK,OAE5BP,MAAMS,eAGH,CAMHC,OAAQ,SAASpB,MACRA,MAASqB,MAAMC,QAAQtB,KAAKuB,SAGjCvB,KAAKuB,OAAOP,QAAQjB"} \ No newline at end of file diff --git a/amd/build/printing.min.js b/amd/build/printing.min.js new file mode 100644 index 000000000..fa8583668 --- /dev/null +++ b/amd/build/printing.min.js @@ -0,0 +1,12 @@ +/** + * Wire the report page's #print-button to window.print(). + * + * Replaces the legacy M.mod_questionnaire.init_printing callback. + * + * @module mod_questionnaire/printing + * @copyright 2026 Mike Churchward (mike.churchward@poetopensource.org) + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +define("mod_questionnaire/printing",[],(function(){return{init:function(){var button=document.getElementById("print-button");button&&button.addEventListener("click",(function(e){e.preventDefault(),window.print()}))}}})); + +//# sourceMappingURL=printing.min.js.map \ No newline at end of file diff --git a/amd/build/printing.min.js.map b/amd/build/printing.min.js.map new file mode 100644 index 000000000..c9cffe251 --- /dev/null +++ b/amd/build/printing.min.js.map @@ -0,0 +1 @@ +{"version":3,"file":"printing.min.js","sources":["../src/printing.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/**\n * Wire the report page's #print-button to window.print().\n *\n * Replaces the legacy M.mod_questionnaire.init_printing callback.\n *\n * @module mod_questionnaire/printing\n * @copyright 2026 Mike Churchward (mike.churchward@poetopensource.org)\n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\ndefine([], function() {\n return {\n /**\n * Attach the print-button click handler.\n */\n init: function() {\n var button = document.getElementById('print-button');\n if (button) {\n button.addEventListener('click', function(e) {\n e.preventDefault();\n window.print();\n });\n }\n }\n };\n});\n"],"names":["define","init","button","document","getElementById","addEventListener","e","preventDefault","window","print"],"mappings":";;;;;;;;;AAwBAA,oCAAO,IAAI,iBACA,CAIHC,KAAM,eACEC,OAASC,SAASC,eAAe,gBACjCF,QACAA,OAAOG,iBAAiB,SAAS,SAASC,GACtCA,EAAEC,iBACFC,OAAOC"} \ No newline at end of file diff --git a/amd/build/sendmessage.min.js b/amd/build/sendmessage.min.js new file mode 100644 index 000000000..cbc9e2b49 --- /dev/null +++ b/amd/build/sendmessage.min.js @@ -0,0 +1,15 @@ +/** + * Bulk-select helpers for the non-respondents list (Send message page). + * + * Replaces the legacy YUI M.mod_questionnaire.init_sendmessage callback. + * Wires #checkall / #checknone / #checknotstarted / #checkstarted to the + * .usercheckbox checkboxes; each checkbox's `alt` attribute holds 0 for + * "not started" and 1 for "started". + * + * @module mod_questionnaire/sendmessage + * @copyright 2026 Mike Churchward (mike.churchward@poetopensource.org) + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +define("mod_questionnaire/sendmessage",[],(function(){var bind=function(id,predicate){var trigger=document.getElementById(id);trigger&&trigger.addEventListener("click",(function(){!function(predicate){document.querySelectorAll("input.usercheckbox").forEach((function(box){box.checked=predicate(box)}))}(predicate)}))};return{init:function(){bind("checkall",(function(){return!0})),bind("checknone",(function(){return!1})),bind("checknotstarted",(function(box){return"0"===box.getAttribute("alt")})),bind("checkstarted",(function(box){return"1"===box.getAttribute("alt")}))}}})); + +//# sourceMappingURL=sendmessage.min.js.map \ No newline at end of file diff --git a/amd/build/sendmessage.min.js.map b/amd/build/sendmessage.min.js.map new file mode 100644 index 000000000..d40b0a373 --- /dev/null +++ b/amd/build/sendmessage.min.js.map @@ -0,0 +1 @@ +{"version":3,"file":"sendmessage.min.js","sources":["../src/sendmessage.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/**\n * Bulk-select helpers for the non-respondents list (Send message page).\n *\n * Replaces the legacy YUI M.mod_questionnaire.init_sendmessage callback.\n * Wires #checkall / #checknone / #checknotstarted / #checkstarted to the\n * .usercheckbox checkboxes; each checkbox's `alt` attribute holds 0 for\n * \"not started\" and 1 for \"started\".\n *\n * @module mod_questionnaire/sendmessage\n * @copyright 2026 Mike Churchward (mike.churchward@poetopensource.org)\n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\ndefine([], function() {\n /**\n * Apply a checked-state function to every .usercheckbox in the document.\n *\n * @param {function(HTMLInputElement): boolean} predicate\n */\n var apply = function(predicate) {\n var boxes = document.querySelectorAll('input.usercheckbox');\n boxes.forEach(function(box) {\n box.checked = predicate(box);\n });\n };\n\n /**\n * Attach a click handler to an element by id (no-op if not present).\n *\n * @param {string} id\n * @param {function(HTMLInputElement): boolean} predicate\n */\n var bind = function(id, predicate) {\n var trigger = document.getElementById(id);\n if (trigger) {\n trigger.addEventListener('click', function() {\n apply(predicate);\n });\n }\n };\n\n return {\n /**\n * Bind the four bulk-select triggers on the Send-message page.\n */\n init: function() {\n bind('checkall', function() {\n return true;\n });\n bind('checknone', function() {\n return false;\n });\n bind('checknotstarted', function(box) {\n return box.getAttribute('alt') === '0';\n });\n bind('checkstarted', function(box) {\n return box.getAttribute('alt') === '1';\n });\n }\n };\n});\n"],"names":["define","bind","id","predicate","trigger","document","getElementById","addEventListener","querySelectorAll","forEach","box","checked","apply","init","getAttribute"],"mappings":";;;;;;;;;;;;AA2BAA,uCAAO,IAAI,eAmBHC,KAAO,SAASC,GAAIC,eAChBC,QAAUC,SAASC,eAAeJ,IAClCE,SACAA,QAAQG,iBAAiB,SAAS,YAhB9B,SAASJ,WACLE,SAASG,iBAAiB,sBAChCC,SAAQ,SAASC,KACnBA,IAAIC,QAAUR,UAAUO,QAcpBE,CAAMT,qBAKX,CAIHU,KAAM,WACFZ,KAAK,YAAY,kBACN,KAEXA,KAAK,aAAa,kBACP,KAEXA,KAAK,mBAAmB,SAASS,WACM,MAA5BA,IAAII,aAAa,UAE5Bb,KAAK,gBAAgB,SAASS,WACS,MAA5BA,IAAII,aAAa"} \ No newline at end of file diff --git a/amd/build/slider.min.js b/amd/build/slider.min.js new file mode 100644 index 000000000..0a5b7c383 --- /dev/null +++ b/amd/build/slider.min.js @@ -0,0 +1,14 @@ +/** + * Slider question type behaviour: animates the value bubble alongside the + * native thumb and renders an off-screen accessibility + * heading describing min/middle/max labels. + * + * Replaces the legacy M.mod_questionnaire.init_slider callback. + * + * @module mod_questionnaire/slider + * @copyright 2026 Mike Churchward (mike.churchward@poetopensource.org) + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +define("mod_questionnaire/slider",[],(function(){var setBubble=function(range,bubble){var val=range.value,min=range.min?range.min:0,max=range.max?range.max:100,newVal=Number(100*(val-min)/(max-min)),positiveVal="";range.min&&range.min<0&&range.max&&range.max>0&&val>0&&(positiveVal="+"),bubble.innerHTML=positiveVal+val,bubble.style.left="calc("+newVal+"% + ("+(8-.15*newVal)+"px))"},createAccessibilityHeading=function(range,bubble,labels){var min=range.min?range.min:0,max=range.max?range.max:100,step=range.step?range.step:1,centerValues=function(range){var min=parseInt(range.min),max=parseInt(range.max),step=parseInt(range.step),numSteps=(max-min)/step,centerValues=[];if(numSteps%2==0)centerValues.push((min+max)/2);else{var lowerCenter=min+Math.floor(numSteps/2)*step;centerValues.push(lowerCenter,lowerCenter+step)}return centerValues}(range),accesshideElement=document.createElement("h2");accesshideElement.classList.add("accesshide");var middleKey,a={min:min,max:max,leftlabel:labels.leftlabel.innerHTML,rightlabel:labels.rightlabel.innerHTML,middlelabel:labels.middlelabel.innerHTML,centreval:centerValues[0],centreval1:centerValues[0],centreval2:centerValues[1]},numSteps=(max-min)/step,hasMiddleLabel=!!labels.middlelabel.innerHTML;middleKey=numSteps%2!=0?hasMiddleLabel?"middlepartwithtwovalues":"middlepartwithtwovaluesdefault":hasMiddleLabel?"middlepart":"middlepartdefault";var middleLabel=M.util.get_string(middleKey,"questionnaire",a),leftPart=labels.leftlabel.innerHTML?M.util.get_string("leftpart","questionnaire",a):M.util.get_string("leftpartdefault","questionnaire",a),middlePart=middleLabel||"",rightPart=labels.rightlabel.innerHTML?M.util.get_string("rightpart","questionnaire",a):M.util.get_string("rightpartdefault","questionnaire",a);accesshideElement.textContent=" "+M.util.get_string("where","questionnaire",a)+leftPart+middlePart+rightPart,bubble.appendChild(accesshideElement)};return{init:function(){document.querySelectorAll(".question-slider").forEach((function(wrap){var range=wrap.querySelector("input.questionnaire-slider"),bubble=wrap.querySelector(".bubble"),labels={leftlabel:wrap.querySelector(".left-side-label"),middlelabel:wrap.querySelector(".middle-side-label"),rightlabel:wrap.querySelector(".right-side-label")};range.addEventListener("input",(function(){setBubble(range,bubble),createAccessibilityHeading(range,bubble,labels)})),setBubble(range,bubble),createAccessibilityHeading(range,bubble,labels)}))}}})); + +//# sourceMappingURL=slider.min.js.map \ No newline at end of file diff --git a/amd/build/slider.min.js.map b/amd/build/slider.min.js.map new file mode 100644 index 000000000..834a4baf1 --- /dev/null +++ b/amd/build/slider.min.js.map @@ -0,0 +1 @@ +{"version":3,"file":"slider.min.js","sources":["../src/slider.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/**\n * Slider question type behaviour: animates the value bubble alongside the\n * native thumb and renders an off-screen accessibility\n * heading describing min/middle/max labels.\n *\n * Replaces the legacy M.mod_questionnaire.init_slider callback.\n *\n * @module mod_questionnaire/slider\n * @copyright 2026 Mike Churchward (mike.churchward@poetopensource.org)\n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\ndefine([], function() {\n /**\n * Calculate the centre value(s) for the slider's range.\n *\n * Returns one value when the range has an even number of steps, two when odd.\n *\n * @param {HTMLInputElement} range\n * @returns {number[]}\n */\n var calculateCenterValues = function(range) {\n var min = parseInt(range.min);\n var max = parseInt(range.max);\n var step = parseInt(range.step);\n var rangeNum = max - min;\n var numSteps = rangeNum / step;\n var centerValues = [];\n if (numSteps % 2 === 0) {\n centerValues.push((min + max) / 2);\n } else {\n var lowerCenter = min + Math.floor(numSteps / 2) * step;\n centerValues.push(lowerCenter, lowerCenter + step);\n }\n return centerValues;\n };\n\n /**\n * Position the value bubble alongside the thumb and write the current value.\n *\n * @param {HTMLInputElement} range\n * @param {HTMLElement} bubble\n */\n var setBubble = function(range, bubble) {\n var val = range.value;\n var min = range.min ? range.min : 0;\n var max = range.max ? range.max : 100;\n var newVal = Number(((val - min) * 100) / (max - min));\n var positiveVal = '';\n if (range.min && range.min < 0 && range.max && range.max > 0 && val > 0) {\n positiveVal = '+';\n }\n bubble.innerHTML = positiveVal + val;\n bubble.style.left = 'calc(' + newVal + '% + (' + (8 - newVal * 0.15) + 'px))';\n };\n\n /**\n * Append an off-screen

that describes the slider for screen readers.\n *\n * @param {HTMLInputElement} range\n * @param {HTMLElement} bubble\n * @param {{leftlabel: HTMLElement, middlelabel: HTMLElement, rightlabel: HTMLElement}} labels\n */\n var createAccessibilityHeading = function(range, bubble, labels) {\n var min = range.min ? range.min : 0;\n var max = range.max ? range.max : 100;\n var step = range.step ? range.step : 1;\n var centerValues = calculateCenterValues(range);\n var accesshideElement = document.createElement('h2');\n accesshideElement.classList.add('accesshide');\n\n var a = {\n min: min,\n max: max,\n leftlabel: labels.leftlabel.innerHTML,\n rightlabel: labels.rightlabel.innerHTML,\n middlelabel: labels.middlelabel.innerHTML,\n centreval: centerValues[0],\n centreval1: centerValues[0],\n centreval2: centerValues[1]\n };\n\n var rangeNum = max - min;\n var numSteps = rangeNum / step;\n var hasMiddleLabel = !!labels.middlelabel.innerHTML;\n var middleKey;\n if (numSteps % 2 !== 0) {\n middleKey = hasMiddleLabel ? 'middlepartwithtwovalues' : 'middlepartwithtwovaluesdefault';\n } else {\n middleKey = hasMiddleLabel ? 'middlepart' : 'middlepartdefault';\n }\n var middleLabel = M.util.get_string(middleKey, 'questionnaire', a);\n var leftPart = labels.leftlabel.innerHTML\n ? M.util.get_string('leftpart', 'questionnaire', a)\n : M.util.get_string('leftpartdefault', 'questionnaire', a);\n var middlePart = middleLabel ? middleLabel : '';\n var rightPart = labels.rightlabel.innerHTML\n ? M.util.get_string('rightpart', 'questionnaire', a)\n : M.util.get_string('rightpartdefault', 'questionnaire', a);\n\n // Whitespace at the start accommodates an NVDA screen-reader setting.\n var whitespace = '\\xa0';\n accesshideElement.textContent = whitespace + M.util.get_string('where', 'questionnaire', a) +\n leftPart + middlePart + rightPart;\n bubble.appendChild(accesshideElement);\n };\n\n return {\n /**\n * Initialise every .question-slider on the page.\n */\n init: function() {\n var allRanges = document.querySelectorAll('.question-slider');\n allRanges.forEach(function(wrap) {\n var range = wrap.querySelector('input.questionnaire-slider');\n var bubble = wrap.querySelector('.bubble');\n var labels = {\n leftlabel: wrap.querySelector('.left-side-label'),\n middlelabel: wrap.querySelector('.middle-side-label'),\n rightlabel: wrap.querySelector('.right-side-label')\n };\n range.addEventListener('input', function() {\n setBubble(range, bubble);\n createAccessibilityHeading(range, bubble, labels);\n });\n setBubble(range, bubble);\n createAccessibilityHeading(range, bubble, labels);\n });\n }\n };\n});\n"],"names":["define","setBubble","range","bubble","val","value","min","max","newVal","Number","positiveVal","innerHTML","style","left","createAccessibilityHeading","labels","step","centerValues","parseInt","numSteps","push","lowerCenter","Math","floor","calculateCenterValues","accesshideElement","document","createElement","classList","add","middleKey","a","leftlabel","rightlabel","middlelabel","centreval","centreval1","centreval2","hasMiddleLabel","middleLabel","M","util","get_string","leftPart","middlePart","rightPart","textContent","appendChild","init","querySelectorAll","forEach","wrap","querySelector","addEventListener"],"mappings":";;;;;;;;;;;AA0BAA,kCAAO,IAAI,eA+BHC,UAAY,SAASC,MAAOC,YACxBC,IAAMF,MAAMG,MACZC,IAAMJ,MAAMI,IAAMJ,MAAMI,IAAM,EAC9BC,IAAML,MAAMK,IAAML,MAAMK,IAAM,IAC9BC,OAASC,OAAsB,KAAbL,IAAME,MAAeC,IAAMD,MAC7CI,YAAc,GACdR,MAAMI,KAAOJ,MAAMI,IAAM,GAAKJ,MAAMK,KAAOL,MAAMK,IAAM,GAAKH,IAAM,IAClEM,YAAc,KAElBP,OAAOQ,UAAYD,YAAcN,IACjCD,OAAOS,MAAMC,KAAO,QAAUL,OAAS,SAAW,EAAa,IAATA,QAAiB,QAUvEM,2BAA6B,SAASZ,MAAOC,OAAQY,YACjDT,IAAMJ,MAAMI,IAAMJ,MAAMI,IAAM,EAC9BC,IAAML,MAAMK,IAAML,MAAMK,IAAM,IAC9BS,KAAOd,MAAMc,KAAOd,MAAMc,KAAO,EACjCC,aA9CoB,SAASf,WAC7BI,IAAMY,SAAShB,MAAMI,KACrBC,IAAMW,SAAShB,MAAMK,KACrBS,KAAOE,SAAShB,MAAMc,MAEtBG,UADWZ,IAAMD,KACKU,KACtBC,aAAe,MACfE,SAAW,GAAM,EACjBF,aAAaG,MAAMd,IAAMC,KAAO,OAC7B,KACCc,YAAcf,IAAMgB,KAAKC,MAAMJ,SAAW,GAAKH,KACnDC,aAAaG,KAAKC,YAAaA,YAAcL,aAE1CC,aAiCYO,CAAsBtB,OACrCuB,kBAAoBC,SAASC,cAAc,MAC/CF,kBAAkBG,UAAUC,IAAI,kBAgB5BC,UAdAC,EAAI,CACJzB,IAAKA,IACLC,IAAKA,IACLyB,UAAWjB,OAAOiB,UAAUrB,UAC5BsB,WAAYlB,OAAOkB,WAAWtB,UAC9BuB,YAAanB,OAAOmB,YAAYvB,UAChCwB,UAAWlB,aAAa,GACxBmB,WAAYnB,aAAa,GACzBoB,WAAYpB,aAAa,IAIzBE,UADWZ,IAAMD,KACKU,KACtBsB,iBAAmBvB,OAAOmB,YAAYvB,UAGtCmB,UADAX,SAAW,GAAM,EACLmB,eAAiB,0BAA4B,iCAE7CA,eAAiB,aAAe,wBAE5CC,YAAcC,EAAEC,KAAKC,WAAWZ,UAAW,gBAAiBC,GAC5DY,SAAW5B,OAAOiB,UAAUrB,UAC1B6B,EAAEC,KAAKC,WAAW,WAAY,gBAAiBX,GAC/CS,EAAEC,KAAKC,WAAW,kBAAmB,gBAAiBX,GACxDa,WAAaL,aAA4B,GACzCM,UAAY9B,OAAOkB,WAAWtB,UAC5B6B,EAAEC,KAAKC,WAAW,YAAa,gBAAiBX,GAChDS,EAAEC,KAAKC,WAAW,mBAAoB,gBAAiBX,GAI7DN,kBAAkBqB,YADD,IAC4BN,EAAEC,KAAKC,WAAW,QAAS,gBAAiBX,GACrFY,SAAWC,WAAaC,UAC5B1C,OAAO4C,YAAYtB,0BAGhB,CAIHuB,KAAM,WACctB,SAASuB,iBAAiB,oBAChCC,SAAQ,SAASC,UACnBjD,MAAQiD,KAAKC,cAAc,8BAC3BjD,OAASgD,KAAKC,cAAc,WAC5BrC,OAAS,CACTiB,UAAWmB,KAAKC,cAAc,oBAC9BlB,YAAaiB,KAAKC,cAAc,sBAChCnB,WAAYkB,KAAKC,cAAc,sBAEnClD,MAAMmD,iBAAiB,SAAS,WAC5BpD,UAAUC,MAAOC,QACjBW,2BAA2BZ,MAAOC,OAAQY,WAE9Cd,UAAUC,MAAOC,QACjBW,2BAA2BZ,MAAOC,OAAQY"} \ No newline at end of file diff --git a/amd/build/survey_inputs.min.js b/amd/build/survey_inputs.min.js new file mode 100644 index 000000000..44e1a6cff --- /dev/null +++ b/amd/build/survey_inputs.min.js @@ -0,0 +1,23 @@ +/** + * Survey-response form input behaviours. + * + * Replaces the legacy global other_check() / other_rate_uncheck() functions + * that lived in module.js and were invoked from inline onclick= attributes. + * + * Uses click delegation on the #phpesp_response form against two data + * attributes: + * + * data-questionnaire-other-check on an "Other" text input — + * when clicked, auto-selects the matching radio/checkbox. + * + * data-questionnaire-rate-uncheck on a rate-row radio input — + * when clicked, unchecks every other radio in the same rate-question + * column (used by "no duplicate choices" rate questions). + * + * @module mod_questionnaire/survey_inputs + * @copyright 2026 Mike Churchward (mike.churchward@poetopensource.org) + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +define("mod_questionnaire/survey_inputs",[],(function(){return{init:function(){var form=document.getElementById("phpesp_response");form&&form.addEventListener("click",(function(event){var target=event.target;target&&"INPUT"===target.tagName&&(target.hasAttribute("data-questionnaire-other-check")?function(input){var name=input.name,idx=name.indexOf("o");if(!(idx<0)){var other=name.slice(idx+1),bracket=other.indexOf("]");-1!==bracket&&(other=other.slice(0,bracket));var form=document.getElementById("phpesp_response");if(form)for(var i=0;i.\n\n/**\n * Survey-response form input behaviours.\n *\n * Replaces the legacy global other_check() / other_rate_uncheck() functions\n * that lived in module.js and were invoked from inline onclick= attributes.\n *\n * Uses click delegation on the #phpesp_response form against two data\n * attributes:\n *\n * data-questionnaire-other-check on an \"Other\" text input —\n * when clicked, auto-selects the matching radio/checkbox.\n *\n * data-questionnaire-rate-uncheck on a rate-row radio input —\n * when clicked, unchecks every other radio in the same rate-question\n * column (used by \"no duplicate choices\" rate questions).\n *\n * @module mod_questionnaire/survey_inputs\n * @copyright 2026 Mike Churchward (mike.churchward@poetopensource.org)\n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\ndefine([], function() {\n /**\n * Auto-select the radio/checkbox whose value matches the trailing segment of\n * the clicked \"Other\" text input's name attribute.\n *\n * Ports the original other_check(name) logic from module.js.\n *\n * @param {HTMLInputElement} input The clicked \"Other\" text input.\n */\n var otherCheck = function(input) {\n var name = input.name;\n var idx = name.indexOf('o');\n if (idx < 0) {\n return;\n }\n var other = name.slice(idx + 1);\n var bracket = other.indexOf(']');\n if (bracket !== -1) {\n other = other.slice(0, bracket);\n }\n var form = document.getElementById('phpesp_response');\n if (!form) {\n return;\n }\n for (var i = 0; i < form.elements.length; i++) {\n if (form.elements[i].value === other) {\n form.elements[i].checked = true;\n break;\n }\n }\n };\n\n /**\n * Uncheck every other rate-question radio in the same column (same value,\n * same name prefix up to the first underscore, different name).\n *\n * Ports the original other_rate_uncheck(name, value) logic from module.js.\n *\n * @param {HTMLInputElement} radio The clicked rate-row radio input.\n */\n var rateUncheck = function(radio) {\n var name = radio.name;\n var value = radio.value;\n var underscoreAt = name.indexOf('_');\n if (underscoreAt < 0) {\n return;\n }\n var colName = name.substr(0, underscoreAt);\n var buttons = document.getElementsByTagName('input');\n for (var i = 0; i < buttons.length; i++) {\n var button = buttons[i];\n if (button.type === 'radio'\n && button.name !== name\n && button.value === value\n && button.name.substr(0, underscoreAt) === colName) {\n button.checked = false;\n }\n }\n };\n\n return {\n /**\n * Attach delegated click handlers to the survey response form.\n */\n init: function() {\n var form = document.getElementById('phpesp_response');\n if (!form) {\n return;\n }\n form.addEventListener('click', function(event) {\n var target = event.target;\n if (!target || target.tagName !== 'INPUT') {\n return;\n }\n if (target.hasAttribute('data-questionnaire-other-check')) {\n otherCheck(target);\n } else if (target.hasAttribute('data-questionnaire-rate-uncheck')) {\n rateUncheck(target);\n }\n });\n }\n };\n});\n"],"names":["define","init","form","document","getElementById","addEventListener","event","target","tagName","hasAttribute","input","name","idx","indexOf","other","slice","bracket","i","elements","length","value","checked","otherCheck","radio","underscoreAt","colName","substr","buttons","getElementsByTagName","button","type","rateUncheck"],"mappings":";;;;;;;;;;;;;;;;;;;;AAmCAA,yCAAO,IAAI,iBA4DA,CAIHC,KAAM,eACEC,KAAOC,SAASC,eAAe,mBAC9BF,MAGLA,KAAKG,iBAAiB,SAAS,SAASC,WAChCC,OAASD,MAAMC,OACdA,QAA6B,UAAnBA,OAAOC,UAGlBD,OAAOE,aAAa,kCAjEnB,SAASC,WAClBC,KAAOD,MAAMC,KACbC,IAAMD,KAAKE,QAAQ,UACnBD,IAAM,QAGNE,MAAQH,KAAKI,MAAMH,IAAM,GACzBI,QAAUF,MAAMD,QAAQ,MACX,IAAbG,UACAF,MAAQA,MAAMC,MAAM,EAAGC,cAEvBd,KAAOC,SAASC,eAAe,sBAC9BF,SAGA,IAAIe,EAAI,EAAGA,EAAIf,KAAKgB,SAASC,OAAQF,OAClCf,KAAKgB,SAASD,GAAGG,QAAUN,MAAO,CAClCZ,KAAKgB,SAASD,GAAGI,SAAU,UAiDvBC,CAAWf,QACJA,OAAOE,aAAa,oCApCzB,SAASc,WACnBZ,KAAOY,MAAMZ,KACbS,MAAQG,MAAMH,MACdI,aAAeb,KAAKE,QAAQ,UAC5BW,aAAe,WAGfC,QAAUd,KAAKe,OAAO,EAAGF,cACzBG,QAAUxB,SAASyB,qBAAqB,SACnCX,EAAI,EAAGA,EAAIU,QAAQR,OAAQF,IAAK,KACjCY,OAASF,QAAQV,GACD,UAAhBY,OAAOC,MACJD,OAAOlB,OAASA,MAChBkB,OAAOT,QAAUA,OACjBS,OAAOlB,KAAKe,OAAO,EAAGF,gBAAkBC,UAC3CI,OAAOR,SAAU,IAsBbU,CAAYxB"} \ No newline at end of file diff --git a/amd/src/attempt_form.js b/amd/src/attempt_form.js new file mode 100644 index 000000000..c0c9cc4e4 --- /dev/null +++ b/amd/src/attempt_form.js @@ -0,0 +1,34 @@ +// 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 . + +/** + * Form-change watcher for the survey response form. + * + * Replaces the legacy M.mod_questionnaire.init_attempt_form callback. + * + * @module mod_questionnaire/attempt_form + * @copyright 2026 Mike Churchward (mike.churchward@poetopensource.org) + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +define(['core_form/changechecker'], function(FormChangeChecker) { + return { + /** + * Attach the form-change watcher to the response form. + */ + init: function() { + FormChangeChecker.watchFormById('phpesp_response'); + } + }; +}); diff --git a/amd/src/chart.js b/amd/src/chart.js new file mode 100644 index 000000000..924b60d8d --- /dev/null +++ b/amd/src/chart.js @@ -0,0 +1,74 @@ +// 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 . + +/** + * RGraph chart driver for the feedback scoreboard. + * + * Driven by a declarative spec built in PHP by + * mod_questionnaire\local\feedback\chart_renderer::render() and dispatched via + * $PAGE->requires->js_call_amd('mod_questionnaire/chart', 'render', [spec]). + * + * Each entry in spec.charts describes one canvas to draw: + * { + * canvasId: 'questionnaire-chart-1-primary', + * type: 'Bipolar' | 'VProgress' | 'HBar' | 'Radar' | 'Rose', + * args: [...] // additional constructor args after canvasId + * sets: [['chart.title', 'Your response'], ['chart.xmax', 100], ...] + * } + * + * The module assumes the corresponding RGraph third-party scripts have already + * been loaded into window.RGraph by the caller (chart_renderer queues these + * via $PAGE->requires->js() adjacent to the js_call_amd). + * + * @module mod_questionnaire/chart + * @copyright 2026 Mike Churchward (mike.churchward@poetopensource.org) + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +define([], function() { + /** + * Draw one chart from a spec entry. + * + * @param {{canvasId: string, type: string, args: Array, sets: Array<[string, *]>}} spec + */ + var drawOne = function(spec) { + if (typeof window.RGraph === 'undefined' || typeof window.RGraph[spec.type] !== 'function') { + return; + } + var canvas = document.getElementById(spec.canvasId); + if (!canvas) { + return; + } + var ctorArgs = [spec.canvasId].concat(spec.args || []); + var chart = new (Function.prototype.bind.apply(window.RGraph[spec.type], [null].concat(ctorArgs)))(); + (spec.sets || []).forEach(function(pair) { + chart.Set(pair[0], pair[1]); + }); + chart.Draw(); + }; + + return { + /** + * Render every chart in the spec. + * + * @param {{charts: Array}} spec + */ + render: function(spec) { + if (!spec || !Array.isArray(spec.charts)) { + return; + } + spec.charts.forEach(drawOne); + } + }; +}); diff --git a/amd/src/printing.js b/amd/src/printing.js new file mode 100644 index 000000000..33431c876 --- /dev/null +++ b/amd/src/printing.js @@ -0,0 +1,40 @@ +// 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 . + +/** + * Wire the report page's #print-button to window.print(). + * + * Replaces the legacy M.mod_questionnaire.init_printing callback. + * + * @module mod_questionnaire/printing + * @copyright 2026 Mike Churchward (mike.churchward@poetopensource.org) + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +define([], function() { + return { + /** + * Attach the print-button click handler. + */ + init: function() { + var button = document.getElementById('print-button'); + if (button) { + button.addEventListener('click', function(e) { + e.preventDefault(); + window.print(); + }); + } + } + }; +}); diff --git a/amd/src/sendmessage.js b/amd/src/sendmessage.js new file mode 100644 index 000000000..632aede7c --- /dev/null +++ b/amd/src/sendmessage.js @@ -0,0 +1,75 @@ +// 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 . + +/** + * Bulk-select helpers for the non-respondents list (Send message page). + * + * Replaces the legacy YUI M.mod_questionnaire.init_sendmessage callback. + * Wires #checkall / #checknone / #checknotstarted / #checkstarted to the + * .usercheckbox checkboxes; each checkbox's `alt` attribute holds 0 for + * "not started" and 1 for "started". + * + * @module mod_questionnaire/sendmessage + * @copyright 2026 Mike Churchward (mike.churchward@poetopensource.org) + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +define([], function() { + /** + * Apply a checked-state function to every .usercheckbox in the document. + * + * @param {function(HTMLInputElement): boolean} predicate + */ + var apply = function(predicate) { + var boxes = document.querySelectorAll('input.usercheckbox'); + boxes.forEach(function(box) { + box.checked = predicate(box); + }); + }; + + /** + * Attach a click handler to an element by id (no-op if not present). + * + * @param {string} id + * @param {function(HTMLInputElement): boolean} predicate + */ + var bind = function(id, predicate) { + var trigger = document.getElementById(id); + if (trigger) { + trigger.addEventListener('click', function() { + apply(predicate); + }); + } + }; + + return { + /** + * Bind the four bulk-select triggers on the Send-message page. + */ + init: function() { + bind('checkall', function() { + return true; + }); + bind('checknone', function() { + return false; + }); + bind('checknotstarted', function(box) { + return box.getAttribute('alt') === '0'; + }); + bind('checkstarted', function(box) { + return box.getAttribute('alt') === '1'; + }); + } + }; +}); diff --git a/amd/src/slider.js b/amd/src/slider.js new file mode 100644 index 000000000..f643d86f5 --- /dev/null +++ b/amd/src/slider.js @@ -0,0 +1,145 @@ +// 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 . + +/** + * Slider question type behaviour: animates the value bubble alongside the + * native thumb and renders an off-screen accessibility + * heading describing min/middle/max labels. + * + * Replaces the legacy M.mod_questionnaire.init_slider callback. + * + * @module mod_questionnaire/slider + * @copyright 2026 Mike Churchward (mike.churchward@poetopensource.org) + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +define([], function() { + /** + * Calculate the centre value(s) for the slider's range. + * + * Returns one value when the range has an even number of steps, two when odd. + * + * @param {HTMLInputElement} range + * @returns {number[]} + */ + var calculateCenterValues = function(range) { + var min = parseInt(range.min); + var max = parseInt(range.max); + var step = parseInt(range.step); + var rangeNum = max - min; + var numSteps = rangeNum / step; + var centerValues = []; + if (numSteps % 2 === 0) { + centerValues.push((min + max) / 2); + } else { + var lowerCenter = min + Math.floor(numSteps / 2) * step; + centerValues.push(lowerCenter, lowerCenter + step); + } + return centerValues; + }; + + /** + * Position the value bubble alongside the thumb and write the current value. + * + * @param {HTMLInputElement} range + * @param {HTMLElement} bubble + */ + var setBubble = function(range, bubble) { + var val = range.value; + var min = range.min ? range.min : 0; + var max = range.max ? range.max : 100; + var newVal = Number(((val - min) * 100) / (max - min)); + var positiveVal = ''; + if (range.min && range.min < 0 && range.max && range.max > 0 && val > 0) { + positiveVal = '+'; + } + bubble.innerHTML = positiveVal + val; + bubble.style.left = 'calc(' + newVal + '% + (' + (8 - newVal * 0.15) + 'px))'; + }; + + /** + * Append an off-screen

that describes the slider for screen readers. + * + * @param {HTMLInputElement} range + * @param {HTMLElement} bubble + * @param {{leftlabel: HTMLElement, middlelabel: HTMLElement, rightlabel: HTMLElement}} labels + */ + var createAccessibilityHeading = function(range, bubble, labels) { + var min = range.min ? range.min : 0; + var max = range.max ? range.max : 100; + var step = range.step ? range.step : 1; + var centerValues = calculateCenterValues(range); + var accesshideElement = document.createElement('h2'); + accesshideElement.classList.add('accesshide'); + + var a = { + min: min, + max: max, + leftlabel: labels.leftlabel.innerHTML, + rightlabel: labels.rightlabel.innerHTML, + middlelabel: labels.middlelabel.innerHTML, + centreval: centerValues[0], + centreval1: centerValues[0], + centreval2: centerValues[1] + }; + + var rangeNum = max - min; + var numSteps = rangeNum / step; + var hasMiddleLabel = !!labels.middlelabel.innerHTML; + var middleKey; + if (numSteps % 2 !== 0) { + middleKey = hasMiddleLabel ? 'middlepartwithtwovalues' : 'middlepartwithtwovaluesdefault'; + } else { + middleKey = hasMiddleLabel ? 'middlepart' : 'middlepartdefault'; + } + var middleLabel = M.util.get_string(middleKey, 'questionnaire', a); + var leftPart = labels.leftlabel.innerHTML + ? M.util.get_string('leftpart', 'questionnaire', a) + : M.util.get_string('leftpartdefault', 'questionnaire', a); + var middlePart = middleLabel ? middleLabel : ''; + var rightPart = labels.rightlabel.innerHTML + ? M.util.get_string('rightpart', 'questionnaire', a) + : M.util.get_string('rightpartdefault', 'questionnaire', a); + + // Whitespace at the start accommodates an NVDA screen-reader setting. + var whitespace = '\xa0'; + accesshideElement.textContent = whitespace + M.util.get_string('where', 'questionnaire', a) + + leftPart + middlePart + rightPart; + bubble.appendChild(accesshideElement); + }; + + return { + /** + * Initialise every .question-slider on the page. + */ + init: function() { + var allRanges = document.querySelectorAll('.question-slider'); + allRanges.forEach(function(wrap) { + var range = wrap.querySelector('input.questionnaire-slider'); + var bubble = wrap.querySelector('.bubble'); + var labels = { + leftlabel: wrap.querySelector('.left-side-label'), + middlelabel: wrap.querySelector('.middle-side-label'), + rightlabel: wrap.querySelector('.right-side-label') + }; + range.addEventListener('input', function() { + setBubble(range, bubble); + createAccessibilityHeading(range, bubble, labels); + }); + setBubble(range, bubble); + createAccessibilityHeading(range, bubble, labels); + }); + } + }; +}); diff --git a/amd/src/survey_inputs.js b/amd/src/survey_inputs.js new file mode 100644 index 000000000..470562120 --- /dev/null +++ b/amd/src/survey_inputs.js @@ -0,0 +1,118 @@ +// 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 . + +/** + * Survey-response form input behaviours. + * + * Replaces the legacy global other_check() / other_rate_uncheck() functions + * that lived in module.js and were invoked from inline onclick= attributes. + * + * Uses click delegation on the #phpesp_response form against two data + * attributes: + * + * data-questionnaire-other-check on an "Other" text input — + * when clicked, auto-selects the matching radio/checkbox. + * + * data-questionnaire-rate-uncheck on a rate-row radio input — + * when clicked, unchecks every other radio in the same rate-question + * column (used by "no duplicate choices" rate questions). + * + * @module mod_questionnaire/survey_inputs + * @copyright 2026 Mike Churchward (mike.churchward@poetopensource.org) + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +define([], function() { + /** + * Auto-select the radio/checkbox whose value matches the trailing segment of + * the clicked "Other" text input's name attribute. + * + * Ports the original other_check(name) logic from module.js. + * + * @param {HTMLInputElement} input The clicked "Other" text input. + */ + var otherCheck = function(input) { + var name = input.name; + var idx = name.indexOf('o'); + if (idx < 0) { + return; + } + var other = name.slice(idx + 1); + var bracket = other.indexOf(']'); + if (bracket !== -1) { + other = other.slice(0, bracket); + } + var form = document.getElementById('phpesp_response'); + if (!form) { + return; + } + for (var i = 0; i < form.elements.length; i++) { + if (form.elements[i].value === other) { + form.elements[i].checked = true; + break; + } + } + }; + + /** + * Uncheck every other rate-question radio in the same column (same value, + * same name prefix up to the first underscore, different name). + * + * Ports the original other_rate_uncheck(name, value) logic from module.js. + * + * @param {HTMLInputElement} radio The clicked rate-row radio input. + */ + var rateUncheck = function(radio) { + var name = radio.name; + var value = radio.value; + var underscoreAt = name.indexOf('_'); + if (underscoreAt < 0) { + return; + } + var colName = name.substr(0, underscoreAt); + var buttons = document.getElementsByTagName('input'); + for (var i = 0; i < buttons.length; i++) { + var button = buttons[i]; + if (button.type === 'radio' + && button.name !== name + && button.value === value + && button.name.substr(0, underscoreAt) === colName) { + button.checked = false; + } + } + }; + + return { + /** + * Attach delegated click handlers to the survey response form. + */ + init: function() { + var form = document.getElementById('phpesp_response'); + if (!form) { + return; + } + form.addEventListener('click', function(event) { + var target = event.target; + if (!target || target.tagName !== 'INPUT') { + return; + } + if (target.hasAttribute('data-questionnaire-other-check')) { + otherCheck(target); + } else if (target.hasAttribute('data-questionnaire-rate-uncheck')) { + rateUncheck(target); + } + }); + } + }; +}); diff --git a/backup/moodle2/backup_questionnaire_stepslib.php b/backup/moodle2/backup_questionnaire_stepslib.php index 3d5b4974d..75c4e4f38 100644 --- a/backup/moodle2/backup_questionnaire_stepslib.php +++ b/backup/moodle2/backup_questionnaire_stepslib.php @@ -42,8 +42,8 @@ protected function define_structure() { 'introformat', 'qtype', 'respondenttype', - 'resp_eligible', - 'resp_view', + 'respeligible', + 'respview', 'notifications', 'opendate', 'closedate', @@ -69,13 +69,13 @@ protected function define_structure() { 'subtitle', 'info', 'theme', - 'thanks_page', - 'thank_head', - 'thank_body', + 'thankspage', + 'thankhead', + 'thankbody', 'feedbacksections', 'feedbacknotes', 'feedbackscores', - 'chart_type', + 'charttype', ]); $questions = new backup_nested_element('questions'); @@ -83,8 +83,8 @@ protected function define_structure() { $question = new backup_nested_element('question', ['id'], [ 'surveyid', 'name', - 'type_id', - 'result_id', + 'typeid', + 'resultid', 'length', 'precise', 'position', @@ -96,7 +96,7 @@ protected function define_structure() { $questchoices = new backup_nested_element('quest_choices'); - $questchoice = new backup_nested_element('quest_choice', ['id'], ['question_id', 'content', 'value']); + $questchoice = new backup_nested_element('quest_choice', ['id'], ['questionid', 'content', 'value']); $questdependencies = new backup_nested_element('quest_dependencies'); @@ -143,53 +143,53 @@ protected function define_structure() { $responsebools = new backup_nested_element('response_bools'); - $responsebool = new backup_nested_element('response_bool', ['id'], ['response_id', 'question_id', 'choice_id']); + $responsebool = new backup_nested_element('response_bool', ['id'], ['responseid', 'questionid', 'choiceid']); $responsedates = new backup_nested_element('response_dates'); - $responsedate = new backup_nested_element('response_date', ['id'], ['response_id', 'question_id', 'response']); + $responsedate = new backup_nested_element('response_date', ['id'], ['responseid', 'questionid', 'response']); $responsefiles = new backup_nested_element('response_files'); - $responsefile = new backup_nested_element('response_file', ['id'], ['response_id', 'question_id', 'fileid']); + $responsefile = new backup_nested_element('response_file', ['id'], ['responseid', 'questionid', 'fileid']); $responsemultiples = new backup_nested_element('response_multiples'); $responsemultiple = new backup_nested_element('response_multiple', ['id'], [ - 'response_id', - 'question_id', - 'choice_id', + 'responseid', + 'questionid', + 'choiceid', ]); $responseothers = new backup_nested_element('response_others'); $responseother = new backup_nested_element('response_other', ['id'], [ - 'response_id', - 'question_id', - 'choice_id', + 'responseid', + 'questionid', + 'choiceid', 'response', ]); $responseranks = new backup_nested_element('response_ranks'); $responserank = new backup_nested_element('response_rank', ['id'], [ - 'response_id', - 'question_id', - 'choice_id', + 'responseid', + 'questionid', + 'choiceid', 'rankvalue', ]); $responsesingles = new backup_nested_element('response_singles'); $responsesingle = new backup_nested_element('response_single', ['id'], [ - 'response_id', - 'question_id', - 'choice_id', + 'responseid', + 'questionid', + 'choiceid', ]); $responsetexts = new backup_nested_element('response_texts'); - $responsetext = new backup_nested_element('response_text', ['id'], ['response_id', 'question_id', 'response']); + $responsetext = new backup_nested_element('response_text', ['id'], ['responseid', 'questionid', 'response']); // Build the tree. $questionnaire->add_child($surveys); @@ -255,21 +255,21 @@ protected function define_structure() { $question->set_source_table('questionnaire_question', ['surveyid' => backup::VAR_PARENTID]); $fbsection->set_source_table('questionnaire_fb_sections', ['surveyid' => backup::VAR_PARENTID]); $feedback->set_source_table('questionnaire_feedback', ['sectionid' => backup::VAR_PARENTID]); - $questchoice->set_source_table('questionnaire_quest_choice', ['question_id' => backup::VAR_PARENTID], 'id ASC'); + $questchoice->set_source_table('questionnaire_quest_choice', ['questionid' => backup::VAR_PARENTID], 'id ASC'); $questdependency->set_source_table('questionnaire_dependency', ['questionid' => backup::VAR_PARENTID]); // All the rest of elements only happen if we are including user info. if ($userinfo) { $response->set_source_table('questionnaire_response', ['questionnaireid' => backup::VAR_PARENTID]); - $responsebool->set_source_table('questionnaire_response_bool', ['response_id' => backup::VAR_PARENTID]); - $responsedate->set_source_table('questionnaire_response_date', ['response_id' => backup::VAR_PARENTID]); - $responsefile->set_source_table('questionnaire_response_file', ['response_id' => backup::VAR_PARENTID]); + $responsebool->set_source_table('questionnaire_response_bool', ['responseid' => backup::VAR_PARENTID]); + $responsedate->set_source_table('questionnaire_response_date', ['responseid' => backup::VAR_PARENTID]); + $responsefile->set_source_table('questionnaire_response_file', ['responseid' => backup::VAR_PARENTID]); $responsefile->annotate_files('mod_questionnaire', 'response_file', null); - $responsemultiple->set_source_table('questionnaire_resp_multiple', ['response_id' => backup::VAR_PARENTID]); - $responseother->set_source_table('questionnaire_response_other', ['response_id' => backup::VAR_PARENTID]); - $responserank->set_source_table('questionnaire_response_rank', ['response_id' => backup::VAR_PARENTID]); - $responsesingle->set_source_table('questionnaire_resp_single', ['response_id' => backup::VAR_PARENTID]); - $responsetext->set_source_table('questionnaire_response_text', ['response_id' => backup::VAR_PARENTID]); + $responsemultiple->set_source_table('questionnaire_resp_multiple', ['responseid' => backup::VAR_PARENTID]); + $responseother->set_source_table('questionnaire_response_other', ['responseid' => backup::VAR_PARENTID]); + $responserank->set_source_table('questionnaire_response_rank', ['responseid' => backup::VAR_PARENTID]); + $responsesingle->set_source_table('questionnaire_resp_single', ['responseid' => backup::VAR_PARENTID]); + $responsetext->set_source_table('questionnaire_response_text', ['responseid' => backup::VAR_PARENTID]); } // Define id annotations. diff --git a/backup/moodle2/restore_questionnaire_activity_task.class.php b/backup/moodle2/restore_questionnaire_activity_task.class.php index 36386eb5e..f94de10e6 100644 --- a/backup/moodle2/restore_questionnaire_activity_task.class.php +++ b/backup/moodle2/restore_questionnaire_activity_task.class.php @@ -52,7 +52,7 @@ public static function define_decode_contents() { $contents[] = new restore_decode_content('questionnaire', ['intro'], 'questionnaire'); $contents[] = new restore_decode_content( 'questionnaire_survey', - ['info', 'thank_head', 'thank_body', 'thanks_page', 'feedbacknotes'], + ['info', 'thankhead', 'thankbody', 'thankspage', 'feedbacknotes'], 'questionnaire_survey' ); $contents[] = new restore_decode_content('questionnaire_question', ['content'], 'questionnaire_question'); diff --git a/backup/moodle2/restore_questionnaire_stepslib.php b/backup/moodle2/restore_questionnaire_stepslib.php index 430431325..8fb9eb76b 100644 --- a/backup/moodle2/restore_questionnaire_stepslib.php +++ b/backup/moodle2/restore_questionnaire_stepslib.php @@ -14,7 +14,7 @@ // You should have received a copy of the GNU General Public License // along with Moodle. If not, see . -use mod_questionnaire\feedback\section; +use mod_questionnaire\local\feedback\section; /** * Define all the restore steps that will be used by the restore_questionnaire_activity_task. @@ -177,6 +177,16 @@ protected function process_questionnaire($data) { $data->closedate = $this->apply_date_offset($data->closedate); $data->timemodified = $this->apply_date_offset($data->timemodified); + // Convert renamed fields if necessary. + if (isset($data->resp_eligible)) { + $data->respeligible = $data->resp_eligible; + unset($data->resp_eligible); + } + if (isset($data->resp_view)) { + $data->respview = $data->resp_view; + unset($data->resp_view); + } + // Insert the questionnaire record. $newitemid = $DB->insert_record('questionnaire', $data); // Immediately after inserting "activity" record, call this. @@ -199,6 +209,24 @@ protected function process_questionnaire_survey($data) { $data->feedbacksections = 2; } + // Convert renamed fields if necessary. + if (isset($data->thanks_page)) { + $data->thankspage = $data->thanks_page; + unset($data->thanks_page); + } + if (isset($data->thank_body)) { + $data->thankbody = $data->thank_body; + unset($data->thank_body); + } + if (isset($data->thank_head)) { + $data->thankhead = $data->thank_head; + unset($data->thank_head); + } + if (isset($data->chart_type)) { + $data->charttype = $data->chart_type; + unset($data->chart_type); + } + // Insert the questionnaire_survey record. $newitemid = $DB->insert_record('questionnaire_survey', $data); $this->set_mapping('questionnaire_survey', $oldid, $newitemid, true); @@ -224,6 +252,17 @@ protected function process_questionnaire_question($data) { // Question was deleted, so don't restore it. return; } + + // Convert renamed fields if necessary. + if (isset($data->type_id)) { + $data->typeid = $data->type_id; + unset($data->type_id); + } + if (isset($data->result_id)) { + $data->resultid = $data->result_id; + unset($data->result_id); + } + // Insert the questionnaire_question record. $newitemid = $DB->insert_record('questionnaire_question', $data); $this->set_mapping('questionnaire_question', $oldid, $newitemid, true); @@ -289,8 +328,6 @@ protected function process_questionnaire_quest_choice($data) { $data = (object)$data; - require_once($CFG->dirroot . '/mod/questionnaire/locallib.php'); - // Some old systems had '' instead of NULL. Change it to NULL. if ($data->value === '') { $data->value = null; @@ -299,14 +336,20 @@ protected function process_questionnaire_quest_choice($data) { // Replace the = separator with :: separator in quest_choice content. // This fixes radio button options using old "value"="display" formats. if (($data->value == null || $data->value == 'NULL') && !preg_match("/^([0-9]{1,3}=.*|!other=.*)$/", $data->content)) { - $content = questionnaire_choice_values($data->content); + $content = \mod_questionnaire\local\question\question::parse_choice_content($data->content); if (strpos($content->text, '=')) { $data->content = str_replace('=', '::', $content->text); } } + // Convert renamed fields if necessary. + if (isset($data->question_id)) { + $data->questionid = $data->question_id; + unset($data->question_id); + } + $oldid = $data->id; - $data->question_id = $this->get_new_parentid('questionnaire_question'); + $data->questionid = $this->get_new_parentid('questionnaire_question'); // Insert the questionnaire_quest_choice record. $newitemid = $DB->insert_record('questionnaire_quest_choice', $data); @@ -370,14 +413,29 @@ protected function process_questionnaire_response_bool($data) { global $DB; $data = (object)$data; - $data->response_id = $this->get_new_parentid('questionnaire_response'); - $data->question_id = $this->get_mappingid('questionnaire_question', $data->question_id); + + // Convert renamed fields if necessary. + if (isset($data->response_id)) { + $data->responseid = $data->response_id; + unset($data->response_id); + } + if (isset($data->question_id)) { + $data->questionid = $data->question_id; + unset($data->question_id); + } + if (isset($data->choice_id)) { + $data->choiceid = $data->choice_id; + unset($data->choice_id); + } + + $data->responseid = $this->get_new_parentid('questionnaire_response'); + $data->questionid = $this->get_mappingid('questionnaire_question', $data->questionid); // Insert the questionnaire_response_bool record. $DB->insert_record('questionnaire_response_bool', $data); } - /** + /**` * Process date responses. * @param array $data * @throws dml_exception @@ -386,8 +444,19 @@ protected function process_questionnaire_response_date($data) { global $DB; $data = (object)$data; - $data->response_id = $this->get_new_parentid('questionnaire_response'); - $data->question_id = $this->get_mappingid('questionnaire_question', $data->question_id); + + // Convert renamed fields if necessary. + if (isset($data->response_id)) { + $data->responseid = $data->response_id; + unset($data->response_id); + } + if (isset($data->question_id)) { + $data->questionid = $data->question_id; + unset($data->question_id); + } + + $data->responseid = $this->get_new_parentid('questionnaire_response'); + $data->questionid = $this->get_mappingid('questionnaire_question', $data->questionid); // Insert the questionnaire_response_date record. $DB->insert_record('questionnaire_response_date', $data); @@ -402,8 +471,19 @@ protected function process_questionnaire_response_file($data) { global $DB; $data = (object)$data; - $data->response_id = $this->get_new_parentid('questionnaire_response'); - $data->question_id = $this->get_mappingid('questionnaire_question', $data->question_id); + + // Convert renamed fields if necessary. + if (isset($data->response_id)) { + $data->responseid = $data->response_id; + unset($data->response_id); + } + if (isset($data->question_id)) { + $data->questionid = $data->question_id; + unset($data->question_id); + } + + $data->responseid = $this->get_new_parentid('questionnaire_response'); + $data->questionid = $this->get_mappingid('questionnaire_question', $data->questionid); // Insert the questionnaire_response_file record. $newid = $DB->insert_record('questionnaire_response_file', $data); $this->responsefileids[$newid] = $data->id; @@ -418,9 +498,24 @@ protected function process_questionnaire_response_multiple($data) { global $DB; $data = (object)$data; - $data->response_id = $this->get_new_parentid('questionnaire_response'); - $data->question_id = $this->get_mappingid('questionnaire_question', $data->question_id); - $data->choice_id = $this->get_mappingid('questionnaire_quest_choice', $data->choice_id); + + // Convert renamed fields if necessary. + if (isset($data->response_id)) { + $data->responseid = $data->response_id; + unset($data->response_id); + } + if (isset($data->question_id)) { + $data->questionid = $data->question_id; + unset($data->question_id); + } + if (isset($data->choice_id)) { + $data->choiceid = $data->choice_id; + unset($data->choice_id); + } + + $data->responseid = $this->get_new_parentid('questionnaire_response'); + $data->questionid = $this->get_mappingid('questionnaire_question', $data->questionid); + $data->choiceid = $this->get_mappingid('questionnaire_quest_choice', $data->choiceid); // Insert the questionnaire_resp_multiple record. $DB->insert_record('questionnaire_resp_multiple', $data); @@ -435,9 +530,24 @@ protected function process_questionnaire_response_other($data) { global $DB; $data = (object)$data; - $data->response_id = $this->get_new_parentid('questionnaire_response'); - $data->question_id = $this->get_mappingid('questionnaire_question', $data->question_id); - $data->choice_id = $this->get_mappingid('questionnaire_quest_choice', $data->choice_id); + + // Convert renamed fields if necessary. + if (isset($data->response_id)) { + $data->responseid = $data->response_id; + unset($data->response_id); + } + if (isset($data->question_id)) { + $data->questionid = $data->question_id; + unset($data->question_id); + } + if (isset($data->choice_id)) { + $data->choiceid = $data->choice_id; + unset($data->choice_id); + } + + $data->responseid = $this->get_new_parentid('questionnaire_response'); + $data->questionid = $this->get_mappingid('questionnaire_question', $data->questionid); + $data->choiceid = $this->get_mappingid('questionnaire_quest_choice', $data->choiceid); // Insert the questionnaire_response_other record. $DB->insert_record('questionnaire_response_other', $data); @@ -453,14 +563,28 @@ protected function process_questionnaire_response_rank($data) { $data = (object)$data; + // Convert renamed fields if necessary. + if (isset($data->response_id)) { + $data->responseid = $data->response_id; + unset($data->response_id); + } + if (isset($data->question_id)) { + $data->questionid = $data->question_id; + unset($data->question_id); + } + if (isset($data->choice_id)) { + $data->choiceid = $data->choice_id; + unset($data->choice_id); + } + // Older versions of questionnaire used 'rank' instead of 'rankvalue'. If 'rank' exists, change it to 'rankvalue'. if (isset($data->rank) && !isset($data->rankvalue)) { $data->rankvalue = $data->rank; } - $data->response_id = $this->get_new_parentid('questionnaire_response'); - $data->question_id = $this->get_mappingid('questionnaire_question', $data->question_id); - $data->choice_id = $this->get_mappingid('questionnaire_quest_choice', $data->choice_id); + $data->responseid = $this->get_new_parentid('questionnaire_response'); + $data->questionid = $this->get_mappingid('questionnaire_question', $data->questionid); + $data->choiceid = $this->get_mappingid('questionnaire_quest_choice', $data->choiceid); // Insert the questionnaire_response_rank record. $DB->insert_record('questionnaire_response_rank', $data); @@ -475,9 +599,24 @@ protected function process_questionnaire_response_single($data) { global $DB; $data = (object)$data; - $data->response_id = $this->get_new_parentid('questionnaire_response'); - $data->question_id = $this->get_mappingid('questionnaire_question', $data->question_id); - $data->choice_id = $this->get_mappingid('questionnaire_quest_choice', $data->choice_id); + + // Convert renamed fields if necessary. + if (isset($data->response_id)) { + $data->responseid = $data->response_id; + unset($data->response_id); + } + if (isset($data->question_id)) { + $data->questionid = $data->question_id; + unset($data->question_id); + } + if (isset($data->choice_id)) { + $data->choiceid = $data->choice_id; + unset($data->choice_id); + } + + $data->responseid = $this->get_new_parentid('questionnaire_response'); + $data->questionid = $this->get_mappingid('questionnaire_question', $data->questionid); + $data->choiceid = $this->get_mappingid('questionnaire_quest_choice', $data->choiceid); // Insert the questionnaire_resp_single record. $DB->insert_record('questionnaire_resp_single', $data); @@ -491,8 +630,19 @@ protected function process_questionnaire_response_text($data) { global $DB; $data = (object)$data; - $data->response_id = $this->get_new_parentid('questionnaire_response'); - $data->question_id = $this->get_mappingid('questionnaire_question', $data->question_id); + + // Convert renamed fields if necessary. + if (isset($data->response_id)) { + $data->responseid = $data->response_id; + unset($data->response_id); + } + if (isset($data->question_id)) { + $data->questionid = $data->question_id; + unset($data->question_id); + } + + $data->responseid = $this->get_new_parentid('questionnaire_response'); + $data->questionid = $this->get_mappingid('questionnaire_question', $data->questionid); // Insert the questionnaire_response_text record. $DB->insert_record('questionnaire_response_text', $data); @@ -572,7 +722,7 @@ protected function after_execute() { $newrec->surveyid = $this->get_new_parentid('questionnaire_survey'); $newrec->dependquestionid = $this->get_mappingid('questionnaire_question', $olddependid); // Only change mapping for RADIO and DROP question types, not for YESNO question. - $dependqtype = $DB->get_field('questionnaire_question', 'type_id', ['id' => $newrec->dependquestionid]); + $dependqtype = $DB->get_field('questionnaire_question', 'typeid', ['id' => $newrec->dependquestionid]); if (($dependqtype !== false) && ($dependqtype != 1)) { $newrec->dependchoiceid = $this->get_mappingid( 'questionnaire_quest_choice', @@ -591,7 +741,7 @@ protected function after_execute() { $data->dependquestionid = $this->get_mappingid('questionnaire_question', $data->dependquestionid); // Only change mapping for RADIO and DROP question types, not for YESNO question. - $dependqtype = $DB->get_field('questionnaire_question', 'type_id', ['id' => $data->dependquestionid]); + $dependqtype = $DB->get_field('questionnaire_question', 'typeid', ['id' => $data->dependquestionid]); if (($dependqtype !== false) && ($dependqtype != 1)) { $data->dependchoiceid = $this->get_mappingid('questionnaire_quest_choice', $data->dependchoiceid); } @@ -610,7 +760,7 @@ protected function after_execute() { // Process any old rate question named degree choices after all questions and choices have been restored. if ($this->task->get_old_moduleversion() < 2018110103) { - \mod_questionnaire\question\rate::move_all_nameddegree_choices($this->get_new_parentid('questionnaire_survey')); + \mod_questionnaire\local\question\rate::move_all_nameddegree_choices($this->get_new_parentid('questionnaire_survey')); } } } diff --git a/classes/capabilities.php b/classes/capabilities.php new file mode 100644 index 000000000..6b1020539 --- /dev/null +++ b/classes/capabilities.php @@ -0,0 +1,367 @@ +. + +namespace mod_questionnaire; + +/** + * Permission and eligibility checks for a questionnaire. + * + * Holds a reference to a questionnaire and answers "may this user do X?" + * questions. Wraps Moodle has_capability() calls, plus the composite policy + * rules (response-view restrictions, attempt-frequency timing). + * + * @package mod_questionnaire + * @copyright 2026 onward Mike Churchward (mike.churchward@poetopensource.org) + * @author Mike Churchward + * @license http://www.gnu.org/copyleft/gpl.html GNU Public License + */ +class capabilities { + /** @var questionnaire The questionnaire whose permissions this object answers. */ + private questionnaire $questionnaire; + + /** + * Constructor. + * + * @param questionnaire $questionnaire + */ + public function __construct(questionnaire $questionnaire) { + $this->questionnaire = $questionnaire; + } + + /** + * True if the specified user is eligible to view and submit this questionnaire. + * + * @param int|null $userid Defaults to current user. + * @return bool + */ + public function user_is_eligible(?int $userid = null): bool { + $context = $this->questionnaire->context(); + return has_capability('mod/questionnaire:view', $context, $userid) && + has_capability('mod/questionnaire:submit', $context, $userid); + } + + /** + * True if the specified user can read their own responses. + * + * @param int|null $userid + * @return bool + */ + public function can_read_own_responses(?int $userid = null): bool { + return has_capability('mod/questionnaire:readownresponses', $this->questionnaire->context(), $userid); + } + + /** + * True if the specified user can view a single response. + * + * @param int|null $userid + * @return bool + */ + public function can_view_single_response(?int $userid = null): bool { + return has_capability('mod/questionnaire:viewsingleresponse', $this->questionnaire->context(), $userid); + } + + /** + * True if the specified user can delete responses. + * + * @param int|null $userid + * @return bool + */ + public function can_delete_responses(?int $userid = null): bool { + return has_capability('mod/questionnaire:deleteresponses', $this->questionnaire->context(), $userid); + } + + /** + * True if the specified user can download responses. + * + * @param int|null $userid + * @return bool + */ + public function can_download_responses(?int $userid = null): bool { + return has_capability('mod/questionnaire:downloadresponses', $this->questionnaire->context(), $userid); + } + + /** + * True if the specified user can manage this questionnaire. + * + * @param int|null $userid + * @return bool + */ + public function can_manage_questionnaire(?int $userid = null): bool { + return has_capability('mod/questionnaire:manage', $this->questionnaire->context(), $userid); + } + + /** + * True if the specified user can edit questions in this questionnaire. + * + * @param int|null $userid + * @return bool + */ + public function can_edit_questions(?int $userid = null): bool { + return has_capability('mod/questionnaire:editquestions', $this->questionnaire->context(), $userid); + } + + /** + * True if the current user can view this questionnaire. + * + * @return bool + */ + public function can_view(): bool { + return has_capability('mod/questionnaire:view', $this->questionnaire->context()); + } + + /** + * True if the current user can preview this questionnaire. + * + * @return bool + */ + public function can_preview(): bool { + return has_capability('mod/questionnaire:preview', $this->questionnaire->context()); + } + + /** + * True if the current user can print a blank copy of this questionnaire. + * + * Returns false in survey-only mode (no real module instance, so the print.php + * link cannot be built). + * + * @return bool + */ + public function can_print_blank(): bool { + if ($this->questionnaire->coursemodule() === null) { + return false; + } + return has_capability('mod/questionnaire:printblank', $this->questionnaire->context()); + } + + /** + * True if the current user can create template surveys. + * + * @return bool + */ + public function can_create_templates(): bool { + return has_capability('mod/questionnaire:createtemplates', $this->questionnaire->context()); + } + + /** + * True if the current user can create public surveys. + * + * @return bool + */ + public function can_create_public(): bool { + return has_capability('mod/questionnaire:createpublic', $this->questionnaire->context()); + } + + /** + * Return true if the current user has site-level access to all groups. + * + * @return bool + */ + public function can_view_all_groups(): bool { + return has_capability('moodle/site:accessallgroups', $this->questionnaire->context()); + } + + /** + * True if the specified user is allowed to take this questionnaire right now. + * + * @param int $userid + * @return bool + */ + public function user_can_take(int $userid): bool { + if (!$this->questionnaire->is_active() || !$this->user_is_eligible($userid)) { + return false; + } else if ($this->questionnaire->qtype() == questionnaire::QTYPE_UNLIMITED) { + return true; + } else if ($userid > 0) { + return $this->user_time_for_new_attempt($userid); + } else { + return false; + } + } + + /** + * True if the timing rules allow the user to start a new attempt. + * + * @param int $userid + * @return bool + */ + public function user_time_for_new_attempt(int $userid): bool { + $attempt = \mod_questionnaire\local\db\response_record::get_latest_complete_for_user( + $this->questionnaire->id(), + $userid + ); + if ($attempt === null) { + return true; + } + + $lastsubmitted = (int)$attempt->get('submitted'); + $timenow = time(); + + switch ($this->questionnaire->qtype()) { + case questionnaire::QTYPE_UNLIMITED: + return true; + + case questionnaire::QTYPE_ONCE: + return false; + + case questionnaire::QTYPE_DAILY: + return (date('Y', $lastsubmitted) < date('Y', $timenow)) || + (date('Yz', $lastsubmitted) < date('Yz', $timenow)); + + case questionnaire::QTYPE_WEEKLY: + return (date('Y', $lastsubmitted) < date('Y', $timenow)) || + (date('YW', $lastsubmitted) < date('YW', $timenow)); + + case questionnaire::QTYPE_MONTHLY: + return (date('Y', $lastsubmitted) < date('Y', $timenow)) || + (date('Yn', $lastsubmitted) < date('Yn', $timenow)); + + default: + return false; + } + } + + /** + * True if the current user can view all responses (checking group membership and response counts). + * + * @param int|null $usernumresp Number of responses the user has made; null to calculate. + * @param bool $isviewreport Whether the context is a view-report page. + * @return bool + */ + public function can_view_all_responses(?int $usernumresp = null, bool $isviewreport = false): bool { + global $USER; + + $numresp = $this->questionnaire->count_submissions(); + if ($usernumresp === null) { + $usernumresp = $this->questionnaire->count_submissions($USER->id); + } + + $context = $this->questionnaire->context(); + $cm = $this->questionnaire->coursemodule(); + $canviewallgroups = has_capability('moodle/site:accessallgroups', $context); + $groupmode = groups_get_activity_groupmode($cm, $this->questionnaire->courseid()); + $canviewgroups = ($groupmode == 1) + ? groups_has_membership($cm, $USER->id) + : true; + + $grouplogic = $canviewgroups || $canviewallgroups; + $respslogic = ($numresp > 0) || $isviewreport; + + return $this->can_view_all_responses_anytime($grouplogic, $respslogic) || + $this->can_view_all_responses_with_restrictions($usernumresp, $grouplogic, $respslogic); + } + + /** + * True if the user can view all responses at any time (no submission requirement). + * + * @param bool $grouplogic + * @param bool $respslogic + * @return bool + */ + public function can_view_all_responses_anytime(bool $grouplogic = true, bool $respslogic = true): bool { + return $grouplogic && $respslogic && $this->questionnaire->is_survey_owner() && + has_capability('mod/questionnaire:readallresponseanytime', $this->questionnaire->context()); + } + + /** + * True if the user can view all responses subject to the questionnaire's view restrictions. + * + * @param int|null $usernumresp + * @param bool $grouplogic + * @param bool $respslogic + * @return bool + */ + public function can_view_all_responses_with_restrictions( + ?int $usernumresp, + bool $grouplogic = true, + bool $respslogic = true + ): bool { + $respview = $this->questionnaire->respview(); + return $grouplogic && $respslogic && $this->questionnaire->is_survey_owner() && + has_capability('mod/questionnaire:readallresponses', $this->questionnaire->context()) && + ($respview == questionnaire::RESPVIEW_ALWAYS || + ($respview == questionnaire::RESPVIEW_WHENCLOSED && $this->questionnaire->is_closed()) || + ($respview == questionnaire::RESPVIEW_WHENANSWERED && $usernumresp)); + } + + /** + * True if the current user can view the specified response (or any response if $rid is 0). + * + * @param int $rid Response id to check, or 0 to check general viewing rights. + * @return bool + */ + public function can_view_response(int $rid = 0): bool { + global $USER, $DB; + + $context = $this->questionnaire->context(); + $respview = $this->questionnaire->respview(); + + if (!empty($rid)) { + $response = $DB->get_record('questionnaire_response', ['id' => $rid]); + + // Response not found or belongs to a different questionnaire. + if (empty($response) || $response->questionnaireid != $this->questionnaire->id()) { + return false; + } + + // Can always view if you have the unrestricted capability. + if (has_capability('mod/questionnaire:readallresponseanytime', $context)) { + return true; + } + + // Can view other users' responses if capability is set and view conditions are met. + if ( + has_capability('mod/questionnaire:readallresponses', $context) && + ($respview == questionnaire::RESPVIEW_ALWAYS || + ($respview == questionnaire::RESPVIEW_WHENCLOSED && $this->questionnaire->is_closed()) || + ($respview == questionnaire::RESPVIEW_WHENANSWERED && !$this->user_can_take($USER->id))) + ) { + return true; + } + + // Can view own response. + if ( + $response->userid == $USER->id && + has_capability('mod/questionnaire:readownresponses', $context) && + $this->questionnaire->count_submissions($USER->id) > 0 + ) { + return true; + } + } else { + // No specific response — check general viewing rights. + if (has_capability('mod/questionnaire:readallresponseanytime', $context)) { + return true; + } + + if ( + has_capability('mod/questionnaire:readallresponses', $context) && + ($respview == questionnaire::RESPVIEW_ALWAYS || + ($respview == questionnaire::RESPVIEW_WHENCLOSED && $this->questionnaire->is_closed()) || + ($respview == questionnaire::RESPVIEW_WHENANSWERED && !$this->user_can_take($USER->id))) + ) { + return true; + } + + if ( + has_capability('mod/questionnaire:readownresponses', $context) && + $this->questionnaire->count_submissions($USER->id) > 0 + ) { + return true; + } + } + + return false; + } +} diff --git a/classes/completion/custom_completion.php b/classes/completion/custom_completion.php index e946bb3af..93cf54d71 100644 --- a/classes/completion/custom_completion.php +++ b/classes/completion/custom_completion.php @@ -18,12 +18,9 @@ namespace mod_questionnaire\completion; -defined('MOODLE_INTERNAL') || die(); - -require_once($CFG->dirroot . '/mod/questionnaire/lib.php'); - use coding_exception; use core_completion\activity_custom_completion; +use mod_questionnaire\questionnaire; use moodle_exception; /** @@ -45,9 +42,8 @@ class custom_completion extends activity_custom_completion { */ public function get_state(string $rule): int { $this->validate_rule($rule); - $userid = $this->userid; - $cm = $this->cm; - $status = questionnaire_get_completion_state($cm, $userid, $rule); + $questionnaire = questionnaire::from_cm($this->cm); + $status = $questionnaire->completionsubmit() && $questionnaire->user_has_submitted($this->userid); return $status ? COMPLETION_COMPLETE : COMPLETION_INCOMPLETE; } diff --git a/classes/courseformat/overview.php b/classes/courseformat/overview.php index 8abb38091..38447b056 100644 --- a/classes/courseformat/overview.php +++ b/classes/courseformat/overview.php @@ -18,7 +18,7 @@ use cm_info; use core\output\pix_icon; -use mod_questionnaire\manager; +use mod_questionnaire\questionnaire; use core\activity_dates; use core\output\action_link; use core_calendar\output\humandate; @@ -34,10 +34,8 @@ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class overview extends \core_courseformat\activityoverviewbase { - /** - * @var manager the questionnaire manager. - */ - private manager $manager; + /** @var questionnaire The questionnaire domain object for this activity. */ + private questionnaire $questionnaire; /** * Constructor. @@ -51,7 +49,7 @@ public function __construct( protected readonly \core\output\renderer_helper $rendererhelper, ) { parent::__construct($cm); - $this->manager = manager::create_from_coursemodule($cm); + $this->questionnaire = questionnaire::from_cm($cm); } #[\Override] @@ -89,7 +87,7 @@ public function get_actions_overview(): ?overviewitem { return null; } - $currentanswerscount = $this->manager->count_all_users_answered(); + $currentanswerscount = $this->questionnaire->responses()->count_distinct_responders(); if ( class_exists(button::class) && @@ -129,11 +127,13 @@ public function get_extra_overview_items(): array { * @return overviewitem|null An overview item or null for teachers. */ private function get_extra_status_for_user(): ?overviewitem { + global $USER; + if (has_capability('mod/questionnaire:viewsingleresponse', $this->cm->context)) { return null; } - $status = $this->manager->has_answered(); + $status = $this->questionnaire->user_has_submitted($USER->id); $statustext = get_string('notanswered', 'questionnaire'); if ($status) { $statustext = get_string('answered', 'questionnaire'); @@ -170,7 +170,7 @@ private function get_extra_students_who_responded_overview(): ?overviewitem { } else { $groupids = []; } - $studentswhoresponded = $this->manager->count_all_users_answered($groupids); + $studentswhoresponded = $this->questionnaire->responses()->count_distinct_responders($groupids); return new overviewitem( name: get_string('studentwhoresponded', 'questionnaire'), diff --git a/classes/db/bulk_sql_config.php b/classes/db/bulk_sql_config.php index 8703dcf37..5591ba89a 100644 --- a/classes/db/bulk_sql_config.php +++ b/classes/db/bulk_sql_config.php @@ -71,7 +71,7 @@ public function __construct($table, $tablealias, $usechoiceid = false, $userespo */ public function get_extra_select() { return [ - 'choice_id' => $this->usechoiceid, + 'choiceid' => $this->usechoiceid, 'response' => $this->useresponse, 'rankvalue' => $this->userank, ]; diff --git a/classes/edit_question_form.php b/classes/edit_question_form.php index e7666b93d..c49619e1f 100644 --- a/classes/edit_question_form.php +++ b/classes/edit_question_form.php @@ -40,14 +40,14 @@ class edit_question_form extends \moodleform { */ public function definition() { // TODO - Find a way to not use globals. Maybe the base class allows more parameters to be passed? - global $questionnaire, $question, $SESSION; + global $questionnaire, $question; - // TODO - Is there a better way to do this without session global? - // The 'sticky' required response value for further new questions. - if (isset($SESSION->questionnaire->required) && !isset($question->qid)) { - $question->required = $SESSION->questionnaire->required; + // The 'sticky' required response value for further new questions — carried via the lastrequired URL/POST param. + $lastrequired = optional_param('lastrequired', '', PARAM_ALPHA); + if ($lastrequired !== '' && !isset($question->qid)) { + $question->set_required_value($lastrequired); } - if (!isset($question->type_id)) { + if ($question->typeid() === 0) { throw new \moodle_exception('undefinedquestiontype', 'mod_questionnaire'); } @@ -69,7 +69,7 @@ public function validation($data, $files) { $errors = parent::validation($data, $files); // If this is a rate question. - if ($data['type_id'] == QUESRATE) { + if ($data['typeid'] == QUESRATE) { if ($data['length'] < 2) { $errors["length"] = get_string('notenoughscaleitems', 'questionnaire'); } @@ -90,7 +90,7 @@ public function validation($data, $files) { } // If this is a slider question. - if ($data['type_id'] == QUESSLIDER) { + if ($data['typeid'] == QUESSLIDER) { if ( isset($data['minrange']) && isset($data['maxrange']) && diff --git a/classes/external/submit_questionnaire_response.php b/classes/external/submit_questionnaire_response.php new file mode 100644 index 000000000..1b130fb73 --- /dev/null +++ b/classes/external/submit_questionnaire_response.php @@ -0,0 +1,138 @@ +. + +/** + * Submit a questionnaire response (external function). + * + * @package mod_questionnaire + * @category external + * @copyright 2018 Igor Sazonov + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace mod_questionnaire\external; + +use core_external\external_api; +use core_external\external_function_parameters; +use core_external\external_multiple_structure; +use core_external\external_single_structure; +use core_external\external_value; +use core_external\external_warnings; +use mod_questionnaire\questionnaire; + +/** + * External function: submit a questionnaire response (mobile API endpoint). + */ +class submit_questionnaire_response extends external_api { + /** + * Describes the parameters for execute(). + * + * @return external_function_parameters + */ + public static function execute_parameters(): external_function_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 execute( + int $questionnaireid, + int $surveyid, + int $userid, + int $cmid, + int $sec, + int $completed, + int $rid, + int $submit, + string $action, + array $responses + ): array { + self::validate_parameters( + self::execute_parameters(), + [ + 'questionnaireid' => $questionnaireid, + 'surveyid' => $surveyid, + 'userid' => $userid, + 'cmid' => $cmid, + 'sec' => $sec, + 'completed' => $completed, + 'rid' => $rid, + 'submit' => $submit, + 'action' => $action, + 'responses' => $responses, + ] + ); + + $questionnaire = questionnaire::from_cmid($cmid); + + self::validate_context($questionnaire->context()); + + require_capability('mod/questionnaire:submit', $questionnaire->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 execute() return value. + * + * @return external_single_structure + */ + public static function execute_returns(): external_single_structure { + return new external_single_structure([ + 'submitted' => new external_value(PARAM_BOOL, 'submitted', VALUE_REQUIRED, false, false), + 'warnings' => new external_warnings(), + ]); + } +} diff --git a/classes/form_options.php b/classes/form_options.php new file mode 100644 index 000000000..9d9a07f29 --- /dev/null +++ b/classes/form_options.php @@ -0,0 +1,131 @@ +. + +namespace mod_questionnaire; + +use context; + +/** + * Static helpers that return option arrays and editor configs for questionnaire forms. + * + * Pure presentation helpers — no questionnaire instance state required. + * + * @package mod_questionnaire + * @copyright 2026 onward Mike Churchward (mike.churchward@poetopensource.org) + * @author Mike Churchward + * @license http://www.gnu.org/copyleft/gpl.html GNU Public License + */ +class form_options { + /** + * Return a localised value => label map of response-frequency options for form selects. + * + * @return array + */ + public static function response_frequency(): array { + return [ + questionnaire::QTYPE_UNLIMITED => get_string('qtypeunlimited', 'questionnaire'), + questionnaire::QTYPE_ONCE => get_string('qtypeonce', 'questionnaire'), + questionnaire::QTYPE_DAILY => get_string('qtypedaily', 'questionnaire'), + questionnaire::QTYPE_WEEKLY => get_string('qtypeweekly', 'questionnaire'), + questionnaire::QTYPE_MONTHLY => get_string('qtypemonthly', 'questionnaire'), + ]; + } + + /** + * Return a localised value => label map of student-response-viewer options for form selects. + * + * @return array + */ + public static function response_viewer(): array { + return [ + questionnaire::RESPVIEW_WHENANSWERED => get_string('responseviewstudentswhenanswered', 'questionnaire'), + questionnaire::RESPVIEW_WHENCLOSED => get_string('responseviewstudentswhenclosed', 'questionnaire'), + questionnaire::RESPVIEW_ALWAYS => get_string('responseviewstudentsalways', 'questionnaire'), + questionnaire::RESPVIEW_NEVER => get_string('responseviewstudentsnever', 'questionnaire'), + ]; + } + + /** + * Return a localised value => label map of respondent-type options for form selects. + * + * @return array + */ + public static function respondent_type(): array { + return [ + 'fullname' => get_string('respondenttypefullname', 'questionnaire'), + 'anonymous' => get_string('respondenttypeanonymous', 'questionnaire'), + ]; + } + + /** + * Return a localised value => label map of survey-realm options for form selects. + * + * @return array + */ + public static function realm(): array { + return [ + 'private' => get_string('private', 'questionnaire'), + 'public' => get_string('public', 'questionnaire'), + 'template' => get_string('template', 'questionnaire'), + ]; + } + + /** + * Return a localised value => label map of auto-numbering options for form selects. + * + * @return array + */ + public static function auto_numbering(): array { + return [ + 0 => get_string('autonumberno', 'questionnaire'), + 1 => get_string('autonumberquestions', 'questionnaire'), + 2 => get_string('autonumberpages', 'questionnaire'), + 3 => get_string('autonumberpagesandquestions', 'questionnaire'), + ]; + } + + /** + * Return the HTML-editor options array for editors used outside Moodle forms. + * + * @param context $context The module context. + * @return array + */ + public static function editor(context $context): array { + return [ + 'subdirs' => 0, + 'maxbytes' => 0, + 'maxfiles' => -1, + 'context' => $context, + 'noclean' => 0, + 'trusttext' => 0, + ]; + } + + /** + * Return a localised value => label map of response-removal duration options for admin form selects. + * + * @return array Keys are seconds (0 = never). + */ + public static function response_removal(): array { + $options = [0 => get_string('removeoldresponsesdefault', 'questionnaire')]; + for ($i = 1; $i <= 36; $i++) { + $options[$i * 2592000] = $i > 1 + ? get_string('nummonths', 'moodle', $i) + : get_string('onemonth', 'questionnaire'); + } + return $options; + } +} diff --git a/classes/generator/question_response.php b/classes/generator/question_response.php index fc4c7221c..be9d91bee 100644 --- a/classes/generator/question_response.php +++ b/classes/generator/question_response.php @@ -16,7 +16,7 @@ namespace mod_questionnaire\generator; -use mod_questionnaire\responsetype\response\response; +use mod_questionnaire\local\response\response; /** * Question response class diff --git a/classes/generator/question_response_rank.php b/classes/generator/question_response_rank.php index 4ff25a7e0..afe9ce332 100644 --- a/classes/generator/question_response_rank.php +++ b/classes/generator/question_response_rank.php @@ -16,7 +16,7 @@ namespace mod_questionnaire\generator; -use mod_questionnaire\question\choice; +use mod_questionnaire\local\question\choice; /** * Question response rank class diff --git a/classes/gradebook.php b/classes/gradebook.php new file mode 100644 index 000000000..7306615c4 --- /dev/null +++ b/classes/gradebook.php @@ -0,0 +1,171 @@ +. + +namespace mod_questionnaire; + +use stdClass; + +/** + * Moodle gradebook integration for the questionnaire module. + * + * Pure static glue between mod_questionnaire and core grade_update(). + * Accepts either a questionnaire domain object or a stdClass record from lib.php. + * + * @package mod_questionnaire + * @copyright 2026 onward Mike Churchward (mike.churchward@poetopensource.org) + * @author Mike Churchward + * @license http://www.gnu.org/copyleft/gpl.html GNU Public License + */ +class gradebook { + /** + * Return grade rows for the given questionnaire and (optional) user. + * + * @param object $questionnaire Either a questionnaire domain object or a stdClass record. + * @param int $userid Optional user id; 0 means all users. + * @return array + */ + public static function get_user_grades(object $questionnaire, int $userid = 0): array { + global $DB; + $qid = ($questionnaire instanceof questionnaire) ? $questionnaire->id() : $questionnaire->id; + $params = []; + $usersql = ''; + if (!empty($userid)) { + $usersql = "AND u.id = ?"; + $params[] = $userid; + } + + $sql = "SELECT r.id, u.id AS userid, r.grade AS rawgrade, r.submitted AS dategraded, r.submitted AS datesubmitted + FROM {user} u, {questionnaire_response} r + WHERE u.id = r.userid AND r.questionnaireid = $qid AND r.complete = 'y' $usersql"; + return $DB->get_records_sql($sql, $params) ?? []; + } + + /** + * Create or update the grade item for the given questionnaire. + * + * @param object $questionnaire Either a questionnaire domain object or a stdClass record (with cmidnumber). + * @param mixed $grades Optional array/object of grade(s); 'reset' means reset grades in gradebook. + * @return int 0 if ok, error code otherwise. + */ + public static function grade_item_update(object $questionnaire, mixed $grades = null): int { + global $CFG; + if (!function_exists('grade_update')) { + require_once($CFG->libdir . '/gradelib.php'); + } + + if ($questionnaire instanceof questionnaire) { + $qid = $questionnaire->id(); + $qname = $questionnaire->name(); + $qgrade = $questionnaire->grade(); + $qcourseid = $questionnaire->courseid(); + $qcmidnumber = $questionnaire->cmidnumber; + } else { + $qid = $questionnaire->instance ?? $questionnaire->id; + $qname = $questionnaire->name; + $qgrade = $questionnaire->grade; + $qcourseid = $questionnaire->courseid ?? $questionnaire->course; + $qcmidnumber = $questionnaire->cmidnumber; + } + + if ($qcmidnumber != '') { + $params = ['itemname' => $qname, 'idnumber' => $qcmidnumber]; + } else { + $params = ['itemname' => $qname]; + } + + if ($qgrade > 0) { + $params['gradetype'] = GRADE_TYPE_VALUE; + $params['grademax'] = $qgrade; + $params['grademin'] = 0; + } else if ($qgrade < 0) { + $params['gradetype'] = GRADE_TYPE_SCALE; + $params['scaleid'] = -$qgrade; + } else if ($qgrade == 0) { + $grades = null; + $params = ['deleted' => 1]; + } else { + $params = null; + } + + if ($grades === 'reset') { + $params['reset'] = true; + $grades = null; + } + + return grade_update( + 'mod/questionnaire', + $qcourseid, + 'mod', + 'questionnaire', + $qid, + 0, + $grades, + $params + ); + } + + /** + * Update grades by firing grade_updated event. + * + * @param object|null $questionnaire Questionnaire instance, or null to update all. + * @param int $userid Optional user id; 0 means all users. + * @param bool $nullifnone Unused; the Moodle API requires it. + * @return void + */ + public static function update_grades(?object $questionnaire = null, int $userid = 0, bool $nullifnone = true): void { + global $CFG, $DB; + + if (!function_exists('grade_update')) { + require_once($CFG->libdir . '/gradelib.php'); + } + + if ($questionnaire != null) { + if ($graderecs = self::get_user_grades($questionnaire, $userid)) { + $grades = []; + foreach ($graderecs as $v) { + if (!isset($grades[$v->userid])) { + $grades[$v->userid] = new stdClass(); + if ($v->rawgrade == -1) { + $grades[$v->userid]->rawgrade = null; + } else { + $grades[$v->userid]->rawgrade = $v->rawgrade; + } + $grades[$v->userid]->userid = $v->userid; + } else if (isset($grades[$v->userid]) && ($v->rawgrade > $grades[$v->userid]->rawgrade)) { + $grades[$v->userid]->rawgrade = $v->rawgrade; + } + } + self::grade_item_update($questionnaire, $grades); + } else { + self::grade_item_update($questionnaire); + } + } else { + $sql = "SELECT q.*, cm.idnumber as cmidnumber, q.course as courseid + FROM {questionnaire} q, {course_modules} cm, {modules} m + WHERE m.name='questionnaire' AND m.id=cm.module AND cm.instance=q.id"; + if ($rs = $DB->get_recordset_sql($sql)) { + foreach ($rs as $questionnaire) { + if ($questionnaire->grade != 0) { + self::update_grades($questionnaire); + } else { + self::grade_item_update($questionnaire); + } + } + $rs->close(); + } + } + } +} diff --git a/classes/instance_admin.php b/classes/instance_admin.php new file mode 100644 index 000000000..02d21d665 --- /dev/null +++ b/classes/instance_admin.php @@ -0,0 +1,333 @@ +. + +namespace mod_questionnaire; + +use mod_questionnaire\local\db\dependency_record; +use mod_questionnaire\local\db\questionnaire_record; +use mod_questionnaire\local\db\survey_record; +use mod_questionnaire\local\survey\survey; +use stdClass; + +/** + * Module instance lifecycle hooks for the questionnaire activity. + * + * Hosts the lib.php callbacks: create / update / delete a questionnaire instance, + * and reset user data on course reset. Also owns calendar event creation. + * + * @package mod_questionnaire + * @copyright 2026 onward Mike Churchward (mike.churchward@poetopensource.org) + * @author Mike Churchward + * @license http://www.gnu.org/copyleft/gpl.html GNU Public License + */ +class instance_admin { + /** @var int Maximum calendar event duration in seconds (5 days). */ + private const MAX_EVENT_LENGTH = 5 * 24 * 60 * 60; + + /** + * Create a new questionnaire activity instance. + * + * Resolves the survey (new blank / copy / existing public), inserts the questionnaire + * row, and fires calendar and completion events. Single point of delegation from + * lib.php's questionnaire_add_instance(). + * + * @param stdClass $formdata Form data from mod_form (coursemodule, course, name, …). + * @return int|false The new questionnaire instance id, or false on failure. + */ + public static function add_instance(stdClass $formdata): int|false { + if (!empty($formdata->sid)) { + // Survey already identified (e.g. restore or duplicate of a public survey). + $sid = $formdata->sid; + } else if ($formdata->create == 'new-0') { + // Brand-new blank survey. + $sdata = new stdClass(); + $sdata->name = $formdata->name; + $sdata->realm = 'private'; + $sdata->title = $formdata->name; + $sdata->subtitle = ''; + $sdata->info = ''; + $sdata->theme = ''; // Theme field is deprecated. + $sdata->thankspage = ''; + $sdata->thankhead = ''; + $sdata->thankbody = ''; + $sdata->email = ''; + $sdata->feedbacknotes = ''; + $sdata->courseid = $formdata->course; + $sid = survey::add_survey($sdata); + } else { + $parts = explode('-', $formdata->create); + $copyrealm = $parts[0]; + $copyid = (int) $parts[1]; + + if ($copyrealm == 'public') { + // Reuse the existing public survey — no copy needed. + $sid = $copyid; + } else { + // Copy the survey, its questions, choices, dependencies, and feedback. + $surveyrecord = (new survey_record($copyid))->to_record(); + $questions = survey::load_questions_for_survey($copyid); + $sid = survey::copy_survey($surveyrecord, $questions, $formdata->course); + + // All new questionnaires should be private, even copies of public/template surveys. + $copied = new survey_record($sid); + $copied->set('realm', 'private'); + $copied->update(); + + // Signal post-actions hook to copy file areas from the original. + $formdata->copyid = $copyid; + } + } + + // Enable navigation if the survey has dependency (skip-logic) data. + if (dependency_record::count_records(['surveyid' => $sid]) > 0) { + $formdata->navigate = 1; + } + $formdata->sid = $sid; + + $formdata->resume = ($formdata->resume == '1') ? 1 : 0; + + // Insert the questionnaire row. + $formdata->id = (questionnaire_record::create_from_formdata($formdata))->get('id'); + + self::set_events($formdata); + + $completiontimeexpected = !empty($formdata->completionexpected) ? $formdata->completionexpected : null; + \core_completion\api::update_completion_date_event( + $formdata->coursemodule, + 'questionnaire', + $formdata->id, + $completiontimeexpected + ); + + return $formdata->id; + } + + /** + * Update an existing questionnaire activity instance. + * + * Updates the survey realm when provided, updates the questionnaire row, and fires + * calendar and completion events. Single point of delegation from lib.php's + * questionnaire_update_instance(). + * + * @param stdClass $questionnaire Form data from mod_form (instance, sid, realm, …). + * @return bool True on success. + */ + public static function update_instance(stdClass $questionnaire): bool { + // Sync the survey realm when the form provides one. + if (!empty($questionnaire->sid) && !empty($questionnaire->realm)) { + $surveyrecord = new survey_record($questionnaire->sid); + $surveyrecord->set('realm', $questionnaire->realm); + $surveyrecord->update(); + } + + $questionnaire->id = $questionnaire->instance; + $questionnaire->resume = ($questionnaire->resume == '1') ? 1 : 0; + + // Update the questionnaire row (before_update() sets timemodified automatically). + $record = new questionnaire_record($questionnaire->id); + foreach ( + ['name', 'intro', 'introformat', 'qtype', 'respondenttype', + 'respeligible', 'respview', 'notifications', 'opendate', 'closedate', + 'resume', 'navigate', 'grade', 'sid', 'completionsubmit', + 'autonum', 'progressbar', 'removeafter'] as $f + ) { + if (isset($questionnaire->$f)) { + $record->set($f, $questionnaire->$f); + } + } + $record->update(); + + self::set_events($questionnaire); + + $completiontimeexpected = !empty($questionnaire->completionexpected) ? $questionnaire->completionexpected : null; + \core_completion\api::update_completion_date_event( + $questionnaire->coursemodule, + 'questionnaire', + $questionnaire->id, + $completiontimeexpected + ); + + return true; + } + + /** + * Delete a questionnaire instance and its survey data (if survey owned by this course). + * + * @param int $id Questionnaire instance id. + * @return bool True on success. + */ + public static function delete_instance(int $id): bool { + global $DB; + + if (!$questionnaire = $DB->get_record('questionnaire', ['id' => $id])) { + return false; + } + + $result = true; + + if ($events = $DB->get_records('event', ['modulename' => 'questionnaire', 'instance' => $questionnaire->id])) { + foreach ($events as $event) { + $event = \calendar_event::load($event); + $event->delete(); + } + } + + // Delete responses owned by this questionnaire instance, then the row itself. + self::delete_responses_for_instance($questionnaire->id); + if (!$DB->delete_records('questionnaire', ['id' => $questionnaire->id])) { + $result = false; + } + + // If the survey is owned by this course, delete the survey content too. + if ($surveyrow = $DB->get_record('questionnaire_survey', ['id' => $questionnaire->sid])) { + if ($surveyrow->courseid == $questionnaire->course) { + $result = $result && survey::from_sid((int) $questionnaire->sid)->delete(); + } + } + + return $result; + } + + /** + * Reset all user data for questionnaires in a course. + * + * @param object $data Data submitted from the reset course form. + * @return array Status array. + */ + public static function reset_userdata(object $data): array { + global $CFG, $DB; + require_once($CFG->libdir . '/questionlib.php'); + + $componentstr = get_string('modulenameplural', 'questionnaire'); + $status = []; + + if (!empty($data->reset_questionnaire)) { + $instances = $DB->get_records('questionnaire', ['course' => $data->courseid]); + foreach ($instances as $instance) { + $questionnaire = questionnaire::from_instanceid($instance->id); + // Delete responses. + $resps = $DB->get_records('questionnaire_response', ['questionnaireid' => $instance->id]); + foreach ($resps as $response) { + $questionnaire->responses()->delete_response($response); + } + // Remove this questionnaire's grades (and feedback) from gradebook (if any). + $select = "itemmodule = 'questionnaire' AND iteminstance = ?"; + if ($itemid = $DB->get_record_select('grade_items', $select, [$instance->id], 'id')) { + $DB->delete_records_select('grade_grades', 'itemid = ' . $itemid->id); + } + } + $status[] = [ + 'component' => $componentstr, + 'item' => get_string('deletedallresp', 'questionnaire'), + 'error' => false, + ]; + + $status[] = [ + 'component' => $componentstr, + 'item' => get_string('gradesdeleted', 'questionnaire'), + 'error' => false, + ]; + } + return $status; + } + + /** + * Create or update calendar events for a questionnaire instance. + * + * Deletes any existing calendar events for the instance, then creates open and/or + * close events based on the questionnaire's opendate and closedate. + * + * @param stdClass $questionnaire Questionnaire instance record (needs id, course, name, opendate, closedate). + * @return void + */ + public static function set_events(stdClass $questionnaire): void { + global $CFG, $DB; + require_once($CFG->dirroot . '/calendar/lib.php'); + + if ($events = $DB->get_records('event', ['modulename' => 'questionnaire', 'instance' => $questionnaire->id])) { + foreach ($events as $event) { + $event = \calendar_event::load($event); + $event->delete(); + } + } + + // The open-event. + $event = new stdClass(); + $event->description = $questionnaire->name; + $event->courseid = $questionnaire->course; + $event->groupid = 0; + $event->userid = 0; + $event->modulename = 'questionnaire'; + $event->instance = $questionnaire->id; + $event->eventtype = 'open'; + $event->type = CALENDAR_EVENT_TYPE_ACTION; + $event->timestart = $questionnaire->opendate; + $event->visible = instance_is_visible('questionnaire', $questionnaire); + $event->timeduration = ($questionnaire->closedate - $questionnaire->opendate); + + if ( + $questionnaire->closedate && $questionnaire->opendate + && ($event->timeduration <= self::MAX_EVENT_LENGTH) + ) { + // Single event for the whole questionnaire. + $event->name = $questionnaire->name; + $event->timesort = $questionnaire->opendate; + \calendar_event::create($event); + } else { + // Separate start and end events. + $event->timeduration = 0; + if ($questionnaire->opendate) { + $event->name = $questionnaire->name . + ' (' . get_string('questionnaireopens', 'questionnaire') . ')'; + $event->timesort = $questionnaire->opendate; + \calendar_event::create($event); + unset($event->id); // So we can use the same object for the close event. + } + if ($questionnaire->closedate) { + $event->name = $questionnaire->name . + ' (' . get_string('questionnairecloses', 'questionnaire') . ')'; + $event->timestart = $questionnaire->closedate; + $event->timesort = $questionnaire->closedate; + $event->eventtype = 'close'; + \calendar_event::create($event); + } + } + } + + /** + * Delete all response records (and their per-question rows) for a questionnaire instance. + * + * @param int $questionnaireid Questionnaire instance id. + * @return void + */ + private static function delete_responses_for_instance(int $questionnaireid): void { + global $DB; + + $rid = $DB->get_fieldset_select('questionnaire_response', 'id', 'questionnaireid = ?', [$questionnaireid]); + if (!empty($rid)) { + [$insql, $params] = $DB->get_in_or_equal($rid); + $DB->delete_records_select('questionnaire_response_bool', "responseid $insql", $params); + $DB->delete_records_select('questionnaire_response_date', "responseid $insql", $params); + $DB->delete_records_select('questionnaire_resp_multiple', "responseid $insql", $params); + $DB->delete_records_select('questionnaire_response_other', "responseid $insql", $params); + $DB->delete_records_select('questionnaire_response_rank', "responseid $insql", $params); + $DB->delete_records_select('questionnaire_resp_single', "responseid $insql", $params); + $DB->delete_records_select('questionnaire_response_text', "responseid $insql", $params); + $DB->delete_records_select('questionnaire_response_file', "responseid $insql", $params); + } + $DB->delete_records('questionnaire_response', ['questionnaireid' => $questionnaireid]); + } +} diff --git a/classes/local/db/choice_record.php b/classes/local/db/choice_record.php new file mode 100644 index 000000000..a4c0d039f --- /dev/null +++ b/classes/local/db/choice_record.php @@ -0,0 +1,74 @@ +. + +namespace mod_questionnaire\local\db; + +/** + * Persistent record for the questionnaire_quest_choice table. + * + * @package mod_questionnaire + * @copyright 2016 Mike Churchward (mike.churchward@poetopensource.org) + * @author Mike Churchward + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class choice_record extends \core\persistent { + /** @var string The table name. */ + public const TABLE = 'questionnaire_quest_choice'; + + /** + * Define the properties of this record. + * + * @return array + */ + protected static function define_properties(): array { + return [ + 'questionid' => [ + 'type' => PARAM_INT, + 'default' => 0, + ], + 'content' => [ + 'type' => PARAM_RAW, + 'default' => '', + ], + 'value' => [ + 'type' => PARAM_RAW, + 'null' => NULL_ALLOWED, + 'default' => null, + ], + ]; + } + + /** + * Return all choices for a given question. + * + * @param int $questionid + * @return choice_record[] + */ + public static function get_for_question(int $questionid): array { + return static::get_records(['questionid' => $questionid]); + } + + /** + * Bulk delete every choice row belonging to the given question. + * + * @param int $questionid + * @return bool + */ + public static function delete_for_question(int $questionid): bool { + global $DB; + return $DB->delete_records(static::TABLE, ['questionid' => $questionid]); + } +} diff --git a/classes/local/db/dependency_record.php b/classes/local/db/dependency_record.php new file mode 100644 index 000000000..d99238d20 --- /dev/null +++ b/classes/local/db/dependency_record.php @@ -0,0 +1,113 @@ +. + +namespace mod_questionnaire\local\db; + +/** + * Persistent record for the questionnaire_dependency table. + * + * Each row defines one condition that must be satisfied for a question to be shown + * (conditional branching / skip logic). Multiple rows for the same questionid are + * combined using the dependandor ('and'/'or') logic. + * + * @package mod_questionnaire + * @copyright 2016 Mike Churchward (mike.churchward@poetopensource.org) + * @author Mike Churchward + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class dependency_record extends \core\persistent { + /** @var string The table name. */ + public const TABLE = 'questionnaire_dependency'; + + /** + * Define the properties of this record. + * + * @return array + */ + protected static function define_properties(): array { + return [ + 'questionid' => [ + 'type' => PARAM_INT, + 'default' => 0, + ], + 'surveyid' => [ + 'type' => PARAM_INT, + 'default' => 0, + ], + 'dependquestionid' => [ + 'type' => PARAM_INT, + 'default' => 0, + ], + 'dependchoiceid' => [ + 'type' => PARAM_INT, + 'default' => 0, + ], + 'dependlogic' => [ + 'type' => PARAM_INT, + 'default' => 0, + ], + 'dependandor' => [ + 'type' => PARAM_ALPHA, + 'default' => '', + ], + ]; + } + + /** + * Return all dependencies for a given question. + * + * @param int $questionid + * @return dependency_record[] + */ + public static function get_for_question(int $questionid): array { + return static::get_records(['questionid' => $questionid]); + } + + /** + * Return all dependencies for all questions in a given survey. + * + * @param int $surveyid + * @return dependency_record[] + */ + public static function get_for_survey(int $surveyid): array { + return static::get_records(['surveyid' => $surveyid]); + } + + /** + * Bulk delete every dependency row belonging to the survey. + * + * @param int $surveyid + * @return bool + */ + public static function delete_for_survey(int $surveyid): bool { + global $DB; + return $DB->delete_records(static::TABLE, ['surveyid' => $surveyid]); + } + + /** + * Bulk delete every dependency that references the given question — both as the + * dependent question and as the question depended on. + * + * @param int $questionid + * @return bool + */ + public static function delete_for_question(int $questionid): bool { + global $DB; + $ok = $DB->delete_records(static::TABLE, ['questionid' => $questionid]); + $ok = $DB->delete_records(static::TABLE, ['dependquestionid' => $questionid]) && $ok; + return $ok; + } +} diff --git a/classes/local/db/feedback_record.php b/classes/local/db/feedback_record.php new file mode 100644 index 000000000..cbccfbfe8 --- /dev/null +++ b/classes/local/db/feedback_record.php @@ -0,0 +1,127 @@ +. + +namespace mod_questionnaire\local\db; + +/** + * Persistent record for the questionnaire_feedback table. + * + * Each row defines a score band within a feedback section. When a respondent's + * score falls between minscore and maxscore, feedbacktext is shown. + * + * @package mod_questionnaire + * @copyright 2016 Mike Churchward (mike.churchward@poetopensource.org) + * @author Mike Churchward + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class feedback_record extends \core\persistent { + /** @var string The table name. */ + public const TABLE = 'questionnaire_feedback'; + + /** + * Define the properties of this record. + * + * @return array + */ + protected static function define_properties(): array { + return [ + 'sectionid' => [ + 'type' => PARAM_INT, + 'default' => 0, + ], + 'feedbacklabel' => [ + 'type' => PARAM_TEXT, + 'null' => NULL_ALLOWED, + 'default' => null, + ], + 'feedbacktext' => [ + 'type' => PARAM_RAW, + 'null' => NULL_ALLOWED, + 'default' => null, + ], + 'feedbacktextformat' => [ + 'type' => PARAM_INT, + 'null' => NULL_ALLOWED, + 'default' => 1, + ], + 'minscore' => [ + 'type' => PARAM_FLOAT, + 'null' => NULL_ALLOWED, + 'default' => 0, + ], + 'maxscore' => [ + 'type' => PARAM_FLOAT, + 'null' => NULL_ALLOWED, + 'default' => 101, + ], + ]; + } + + /** + * Return all feedback bands for a given section. + * + * @param int $sectionid + * @param string $sort Optional sort field name (e.g. 'minscore'). + * @param string $order Sort direction; ignored when $sort is empty. + * @return feedback_record[] + */ + public static function get_for_section(int $sectionid, string $sort = '', string $order = 'ASC'): array { + return static::get_records(['sectionid' => $sectionid], $sort, $order); + } + + /** + * Bulk delete every feedback-message row belonging to the given section. + * + * @param int $sectionid + * @return bool + */ + public static function delete_for_section(int $sectionid): bool { + global $DB; + return $DB->delete_records(static::TABLE, ['sectionid' => $sectionid]); + } + + /** + * Return every feedback-message row belonging to the section, as plain + * stdClass records (matches the shape the scoreboard math currently expects). + * + * @param int $sectionid + * @return \stdClass[] + */ + public static function get_records_for_section(int $sectionid): array { + global $DB; + return $DB->get_records(static::TABLE, ['sectionid' => $sectionid]); + } + + /** + * Find the single feedback-message row whose score band contains the + * given percentage. Returns null when no band matches. + * + * @param int $sectionid + * @param float $scorepercent + * @param string $fields Optional comma-separated field list (defaults to all). + * @return \stdClass|null + */ + public static function find_for_score(int $sectionid, float $scorepercent, string $fields = '*'): ?\stdClass { + global $DB; + $row = $DB->get_record_select( + static::TABLE, + 'sectionid = ? AND minscore <= ? AND ? < maxscore', + [$sectionid, $scorepercent, $scorepercent], + $fields + ); + return $row ?: null; + } +} diff --git a/classes/local/db/feedback_section_record.php b/classes/local/db/feedback_section_record.php new file mode 100644 index 000000000..c76f952a1 --- /dev/null +++ b/classes/local/db/feedback_section_record.php @@ -0,0 +1,138 @@ +. + +namespace mod_questionnaire\local\db; + +/** + * Persistent record for the questionnaire_fb_sections table. + * + * Defines a scored feedback section for a survey. Each section has a score + * calculation expression and optional heading content displayed to the respondent. + * + * @package mod_questionnaire + * @copyright 2016 Mike Churchward (mike.churchward@poetopensource.org) + * @author Mike Churchward + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class feedback_section_record extends \core\persistent { + /** @var string The table name. */ + public const TABLE = 'questionnaire_fb_sections'; + + /** + * Define the properties of this record. + * + * @return array + */ + protected static function define_properties(): array { + return [ + 'surveyid' => [ + 'type' => PARAM_INT, + 'default' => 0, + ], + 'section' => [ + 'type' => PARAM_INT, + 'null' => NULL_ALLOWED, + 'default' => null, + ], + 'scorecalculation' => [ + 'type' => PARAM_RAW, + 'null' => NULL_ALLOWED, + 'default' => null, + ], + 'sectionlabel' => [ + 'type' => PARAM_TEXT, + 'null' => NULL_ALLOWED, + 'default' => null, + ], + 'sectionheading' => [ + 'type' => PARAM_RAW, + 'null' => NULL_ALLOWED, + 'default' => null, + ], + 'sectionheadingformat' => [ + 'type' => PARAM_INT, + 'null' => NULL_ALLOWED, + 'default' => 1, + ], + ]; + } + + /** + * Return all feedback sections for a given survey. + * + * @param int $surveyid + * @param string $sort Optional sort field name (e.g. 'section'). + * @param string $order Sort direction; ignored when $sort is empty. + * @return feedback_section_record[] + */ + public static function get_for_survey(int $surveyid, string $sort = '', string $order = 'ASC'): array { + return static::get_records(['surveyid' => $surveyid], $sort, $order); + } + + /** + * Return the highest section number among feedback sections for the survey, + * or 0 if there are none. + * + * @param int $surveyid + * @return int + */ + public static function max_section_for_survey(int $surveyid): int { + global $DB; + $max = $DB->get_field(static::TABLE, 'MAX(section)', ['surveyid' => $surveyid]); + return (int) ($max ?: 0); + } + + /** + * Return the lowest section number among feedback sections for the survey, + * or 0 if there are none. + * + * @param int $surveyid + * @return int + */ + public static function min_section_for_survey(int $surveyid): int { + global $DB; + $min = $DB->get_field(static::TABLE, 'MIN(section)', ['surveyid' => $surveyid]); + return (int) ($min ?: 0); + } + + /** + * Return every row for the survey that has a populated section number, + * keyed by id, as plain stdClass records (matches the legacy + * $DB->get_records_sql shape used by the feedback scoreboard). + * + * @param int $surveyid + * @return \stdClass[] + */ + public static function get_numbered_section_records_for_survey(int $surveyid): array { + global $DB; + return $DB->get_records_select( + static::TABLE, + 'surveyid = ? AND section IS NOT NULL', + [$surveyid] + ); + } + + /** + * Bulk delete every feedback-section row belonging to the survey. + * + * @param int $surveyid + * @return bool + */ + public static function delete_for_survey(int $surveyid): bool { + global $DB; + return $DB->delete_records(static::TABLE, ['surveyid' => $surveyid]); + } +} diff --git a/classes/local/db/question_record.php b/classes/local/db/question_record.php new file mode 100644 index 000000000..0a7d9b634 --- /dev/null +++ b/classes/local/db/question_record.php @@ -0,0 +1,312 @@ +. + +namespace mod_questionnaire\local\db; + +/** + * Persistent record for the questionnaire_question table. + * + * The column 'precise' holds the precision/max value for applicable types. + * + * @package mod_questionnaire + * @copyright 2016 Mike Churchward (mike.churchward@poetopensource.org) + * @author Mike Churchward + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class question_record extends \core\persistent { + /** @var string The table name. */ + public const TABLE = 'questionnaire_question'; + + /** + * Define the properties of this record. + * + * @return array + */ + protected static function define_properties(): array { + return [ + 'surveyid' => [ + 'type' => PARAM_INT, + 'default' => 0, + ], + 'name' => [ + 'type' => PARAM_TEXT, + 'null' => NULL_ALLOWED, + 'default' => null, + ], + 'typeid' => [ + 'type' => PARAM_INT, + 'default' => 0, + ], + 'resultid' => [ + 'type' => PARAM_INT, + 'null' => NULL_ALLOWED, + 'default' => null, + ], + 'length' => [ + 'type' => PARAM_INT, + 'default' => 0, + ], + 'precise' => [ + 'type' => PARAM_INT, + 'default' => 0, + ], + 'position' => [ + 'type' => PARAM_INT, + 'default' => 0, + ], + 'content' => [ + 'type' => PARAM_RAW, + 'default' => '', + ], + 'required' => [ + 'type' => PARAM_ALPHA, + 'default' => 'n', + ], + 'deleted' => [ + 'type' => PARAM_INT, + 'null' => NULL_ALLOWED, + 'default' => null, + ], + 'extradata' => [ + 'type' => PARAM_RAW, + 'null' => NULL_ALLOWED, + 'default' => null, + ], + ]; + } + + /** + * Return all non-deleted questions for a given survey, ordered by position. + * + * Uses a raw SQL query because Moodle's get_records() cannot express IS NULL conditions. + * + * @param int $surveyid + * @return question_record[] + */ + public static function get_active_for_survey(int $surveyid): array { + global $DB; + $records = $DB->get_records_select( + static::TABLE, + 'surveyid = :surveyid AND deleted IS NULL', + ['surveyid' => $surveyid], + 'position ASC' + ); + return array_map(fn($r) => new static(0, $r), $records); + } + + /** + * Return every question record for the given survey, including soft-deleted rows. + * + * @param int $surveyid + * @return question_record[] + */ + public static function get_for_survey(int $surveyid): array { + return static::get_records(['surveyid' => $surveyid]); + } + + /** + * Return question records of the given type, optionally restricted to a single survey. + * + * @param int $typeid + * @param int|null $surveyid Restrict to this survey, or null for all surveys. + * @return question_record[] + */ + public static function get_for_type(int $typeid, ?int $surveyid = null): array { + $filters = ['typeid' => $typeid]; + if ($surveyid !== null) { + $filters['surveyid'] = $surveyid; + } + return static::get_records($filters); + } + + /** + * Return every soft-deleted question record for the given survey, excluding page breaks, + * ordered by deletion time (most recent first). + * + * @param int $surveyid + * @return question_record[] + */ + public static function get_deleted_for_survey(int $surveyid): array { + global $DB; + $records = $DB->get_records_select( + static::TABLE, + 'deleted IS NOT NULL AND surveyid = :surveyid AND typeid != :pagebreak', + [ + 'surveyid' => $surveyid, + 'pagebreak' => \mod_questionnaire\local\question_type::QUESPAGEBREAK, + ], + 'deleted DESC' + ); + return array_map(fn($r) => new static(0, $r), $records); + } + + /** + * Return a soft-deleted question identified by id and survey, or null if none matches. + * + * @param int $questionid + * @param int $surveyid + * @return self|null + */ + public static function get_soft_deleted(int $questionid, int $surveyid): ?self { + global $DB; + $record = $DB->get_record_select( + static::TABLE, + 'id = :id AND surveyid = :surveyid AND deleted IS NOT NULL', + ['id' => $questionid, 'surveyid' => $surveyid] + ); + return $record ? new static(0, $record) : null; + } + + /** + * Return the highest position among active (non-deleted) questions in the survey, + * or 0 if there are none. + * + * @param int $surveyid + * @return int + */ + public static function max_active_position_for_survey(int $surveyid): int { + global $DB; + $max = $DB->get_field_select( + static::TABLE, + 'MAX(position)', + 'surveyid = :surveyid AND deleted IS NULL', + ['surveyid' => $surveyid] + ); + return (int) ($max ?: 0); + } + + /** + * Count active questions of a given type whose position is below the given value. + * + * @param int $surveyid + * @param int $typeid + * @param int $position + * @return int + */ + public static function count_active_before_position(int $surveyid, int $typeid, int $position): int { + global $DB; + return $DB->count_records_select( + static::TABLE, + 'surveyid = :surveyid AND typeid = :typeid AND position < :position AND deleted IS NULL', + ['surveyid' => $surveyid, 'typeid' => $typeid, 'position' => $position] + ); + } + + /** + * Update only the position field of a question row. + * + * @param int $questionid + * @param int $position + * @return bool + */ + public static function update_position(int $questionid, int $position): bool { + global $DB; + return $DB->set_field(static::TABLE, 'position', $position, ['id' => $questionid]); + } + + /** + * Mark a question as soft-deleted by stamping its deleted column with the current time. + * + * @param int $questionid + * @return bool + */ + public static function soft_delete(int $questionid): bool { + global $DB; + return $DB->set_field(static::TABLE, 'deleted', time(), ['id' => $questionid]); + } + + /** + * Return active questions in the survey whose position is greater than the given one, + * ordered by position ascending. + * + * @param int $surveyid + * @param int $position + * @return question_record[] + */ + public static function get_active_after_position(int $surveyid, int $position): array { + global $DB; + $records = $DB->get_records_select( + static::TABLE, + 'surveyid = :surveyid AND deleted IS NULL AND position > :pos', + ['surveyid' => $surveyid, 'pos' => $position], + 'position ASC' + ); + return array_map(fn($r) => new static(0, $r), $records); + } + + /** + * Insert a new page-break question row at the given position. Returns the created record. + * + * @param int $surveyid + * @param int $position + * @return self + */ + public static function create_pagebreak(int $surveyid, int $position): self { + $record = new static(0, (object)[ + 'surveyid' => $surveyid, + 'typeid' => \mod_questionnaire\local\question_type::QUESPAGEBREAK, + 'position' => $position, + 'content' => 'break', + ]); + $record->create(); + return $record; + } + + /** + * Bulk delete every question row belonging to the survey, regardless of deleted status. + * + * @param int $surveyid + * @return bool + */ + public static function delete_for_survey(int $surveyid): bool { + global $DB; + return $DB->delete_records(static::TABLE, ['surveyid' => $surveyid]); + } + + /** + * Permanently delete a soft-deleted question row identified by id and survey. + * + * No-op when the row is not currently soft-deleted, so callers can use this safely + * without first checking the deleted flag. + * + * @param int $questionid + * @param int $surveyid + * @return bool + */ + public static function delete_soft_deleted(int $questionid, int $surveyid): bool { + global $DB; + return $DB->delete_records_select( + static::TABLE, + 'id = :id AND surveyid = :surveyid AND deleted IS NOT NULL', + ['id' => $questionid, 'surveyid' => $surveyid] + ); + } + + /** + * Permanently delete every soft-deleted page-break question for the survey. + * + * @param int $surveyid + * @return bool + */ + public static function delete_soft_deleted_pagebreaks_for_survey(int $surveyid): bool { + global $DB; + return $DB->delete_records_select( + static::TABLE, + 'surveyid = :surveyid AND deleted IS NOT NULL AND typeid = :typeid', + ['surveyid' => $surveyid, 'typeid' => \mod_questionnaire\local\question_type::QUESPAGEBREAK] + ); + } +} diff --git a/classes/local/db/question_type_record.php b/classes/local/db/question_type_record.php new file mode 100644 index 000000000..13f3563ff --- /dev/null +++ b/classes/local/db/question_type_record.php @@ -0,0 +1,82 @@ +. + +namespace mod_questionnaire\local\db; + +/** + * Persistent record for the questionnaire_question_type table. + * + * This is a static lookup table populated at install time. The typeid column + * is the semantic identifier used throughout the plugin (not the auto-increment id). + * + * @package mod_questionnaire + * @copyright 2016 Mike Churchward (mike.churchward@poetopensource.org) + * @author Mike Churchward + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class question_type_record extends \core\persistent { + /** @var string The table name. */ + public const TABLE = 'questionnaire_question_type'; + + /** + * Define the properties of this record. + * + * @return array + */ + protected static function define_properties(): array { + return [ + 'typeid' => [ + 'type' => PARAM_INT, + 'default' => 0, + ], + 'type' => [ + 'type' => PARAM_TEXT, + 'default' => '', + ], + 'haschoices' => [ + 'type' => PARAM_ALPHA, + 'default' => 'y', + ], + 'responsetable' => [ + 'type' => PARAM_ALPHANUMEXT, + 'null' => NULL_ALLOWED, + 'default' => null, + ], + ]; + } + + /** + * Return the question type record for a given typeid. + * + * @param int $typeid + * @return question_type_record|false + */ + public static function get_by_typeid(int $typeid) { + $records = static::get_records(['typeid' => $typeid]); + return !empty($records) ? reset($records) : false; + } + + /** + * Return the question type record for a given typeid, or null if not found. + * + * @param int $typeid + * @return question_type_record|null + */ + public static function from_typeid(int $typeid): ?self { + $records = static::get_records(['typeid' => $typeid]); + return !empty($records) ? reset($records) : null; + } +} diff --git a/classes/local/db/questionnaire_record.php b/classes/local/db/questionnaire_record.php new file mode 100644 index 000000000..a34cd4038 --- /dev/null +++ b/classes/local/db/questionnaire_record.php @@ -0,0 +1,169 @@ +. + +namespace mod_questionnaire\local\db; + +/** + * Persistent record for the questionnaire table (the activity instance). + * + * @package mod_questionnaire + * @copyright 2016 Mike Churchward (mike.churchward@poetopensource.org) + * @author Mike Churchward + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class questionnaire_record extends \core\persistent { + /** @var string The table name. */ + public const TABLE = 'questionnaire'; + + /** + * Define the properties of this record. + * + * Property names map directly to DB column names. Types use Moodle PARAM_* constants. + * + * @return array + */ + protected static function define_properties(): array { + return [ + 'course' => [ + 'type' => PARAM_INT, + ], + 'name' => [ + 'type' => PARAM_TEXT, + ], + 'intro' => [ + 'type' => PARAM_RAW, + ], + 'introformat' => [ + 'type' => PARAM_INT, + 'default' => FORMAT_HTML, + ], + 'qtype' => [ + 'type' => PARAM_INT, + 'default' => 0, + ], + 'respondenttype' => [ + 'type' => PARAM_ALPHA, + 'default' => 'fullname', + ], + 'respeligible' => [ + 'type' => PARAM_ALPHA, + 'default' => 'all', + ], + 'respview' => [ + 'type' => PARAM_INT, + 'default' => 0, + ], + 'notifications' => [ + 'type' => PARAM_INT, + 'default' => 0, + ], + 'opendate' => [ + 'type' => PARAM_INT, + 'default' => 0, + ], + 'closedate' => [ + 'type' => PARAM_INT, + 'default' => 0, + ], + 'resume' => [ + 'type' => PARAM_INT, + 'default' => 0, + ], + 'navigate' => [ + 'type' => PARAM_INT, + 'default' => 0, + ], + 'grade' => [ + 'type' => PARAM_INT, + 'default' => 0, + ], + 'sid' => [ + 'type' => PARAM_INT, + 'default' => 0, + ], + 'timemodified' => [ + 'type' => PARAM_INT, + 'default' => 0, + ], + 'completionsubmit' => [ + 'type' => PARAM_INT, + 'default' => 0, + ], + 'autonum' => [ + 'type' => PARAM_INT, + 'default' => 3, + ], + 'progressbar' => [ + 'type' => PARAM_INT, + 'default' => 0, + ], + 'removeafter' => [ + 'type' => PARAM_INT, + 'default' => 0, + ], + ]; + } + + /** + * Return the first questionnaire instance linked to the given survey, or null if none exists. + * + * @param int $surveyid + * @return self|null + */ + public static function get_for_survey(int $surveyid): ?self { + $records = static::get_records(['sid' => $surveyid]); + return !empty($records) ? reset($records) : null; + } + + /** + * Return the questionnaire record linked to the given survey within the given course, or null. + * + * Used to find the "original" public questionnaire — the one whose course owns the survey, + * when the current questionnaire is a copy that points at the same shared survey. + * + * @param int $surveyid + * @param int $courseid + * @return self|null + */ + public static function get_for_survey_in_course(int $surveyid, int $courseid): ?self { + $records = static::get_records(['sid' => $surveyid, 'course' => $courseid]); + return !empty($records) ? reset($records) : null; + } + + /** + * Create a new questionnaire record from mod_form data. + * + * @param stdClass $formdata Form data from mod_form. + * @return self The created record. + */ + public static function create_from_formdata(\stdClass $formdata): self { + return (new self(0, $formdata))->create(); + } + + /** + * Set timemodified before creating a new record. + */ + protected function before_create(): void { + $this->raw_set('timemodified', time()); + } + + /** + * Update timemodified before saving changes. + */ + protected function before_update(): void { + $this->raw_set('timemodified', time()); + } +} diff --git a/classes/local/db/response_bool_record.php b/classes/local/db/response_bool_record.php new file mode 100644 index 000000000..436e6cd7b --- /dev/null +++ b/classes/local/db/response_bool_record.php @@ -0,0 +1,64 @@ +. + +namespace mod_questionnaire\local\db; + +/** + * Persistent record for the questionnaire_response_bool table. + * + * Stores yes/no answers. The choiceid column holds 'y' or 'n'. + * + * @package mod_questionnaire + * @copyright 2016 Mike Churchward (mike.churchward@poetopensource.org) + * @author Mike Churchward + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class response_bool_record extends \core\persistent { + /** @var string The table name. */ + public const TABLE = 'questionnaire_response_bool'; + + /** + * Define the properties of this record. + * + * @return array + */ + protected static function define_properties(): array { + return [ + 'responseid' => [ + 'type' => PARAM_INT, + 'default' => 0, + ], + 'questionid' => [ + 'type' => PARAM_INT, + 'default' => 0, + ], + 'choiceid' => [ + 'type' => PARAM_ALPHA, + 'default' => 'y', + ], + ]; + } + + /** + * Return all bool answers for a given response. + * + * @param int $responseid + * @return response_bool_record[] + */ + public static function get_for_response(int $responseid): array { + return static::get_records(['responseid' => $responseid]); + } +} diff --git a/classes/local/db/response_date_record.php b/classes/local/db/response_date_record.php new file mode 100644 index 000000000..4a46d8179 --- /dev/null +++ b/classes/local/db/response_date_record.php @@ -0,0 +1,65 @@ +. + +namespace mod_questionnaire\local\db; + +/** + * Persistent record for the questionnaire_response_date table. + * + * Stores date answers as text strings (e.g. 'yyyy/mm/dd'). + * + * @package mod_questionnaire + * @copyright 2016 Mike Churchward (mike.churchward@poetopensource.org) + * @author Mike Churchward + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class response_date_record extends \core\persistent { + /** @var string The table name. */ + public const TABLE = 'questionnaire_response_date'; + + /** + * Define the properties of this record. + * + * @return array + */ + protected static function define_properties(): array { + return [ + 'responseid' => [ + 'type' => PARAM_INT, + 'default' => 0, + ], + 'questionid' => [ + 'type' => PARAM_INT, + 'default' => 0, + ], + 'response' => [ + 'type' => PARAM_TEXT, + 'null' => NULL_ALLOWED, + 'default' => null, + ], + ]; + } + + /** + * Return all date answers for a given response. + * + * @param int $responseid + * @return response_date_record[] + */ + public static function get_for_response(int $responseid): array { + return static::get_records(['responseid' => $responseid]); + } +} diff --git a/classes/local/db/response_file_record.php b/classes/local/db/response_file_record.php new file mode 100644 index 000000000..00ecbee4c --- /dev/null +++ b/classes/local/db/response_file_record.php @@ -0,0 +1,65 @@ +. + +namespace mod_questionnaire\local\db; + +/** + * Persistent record for the questionnaire_response_file table. + * + * Links a response/question pair to an uploaded file stored in the Moodle + * files table. The fileid is a foreign key into {files}.id. + * + * @package mod_questionnaire + * @copyright 2016 Mike Churchward (mike.churchward@poetopensource.org) + * @author Mike Churchward + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class response_file_record extends \core\persistent { + /** @var string The table name. */ + public const TABLE = 'questionnaire_response_file'; + + /** + * Define the properties of this record. + * + * @return array + */ + protected static function define_properties(): array { + return [ + 'responseid' => [ + 'type' => PARAM_INT, + 'default' => 0, + ], + 'questionid' => [ + 'type' => PARAM_INT, + 'default' => 0, + ], + 'fileid' => [ + 'type' => PARAM_INT, + 'default' => 0, + ], + ]; + } + + /** + * Return all file answers for a given response. + * + * @param int $responseid + * @return response_file_record[] + */ + public static function get_for_response(int $responseid): array { + return static::get_records(['responseid' => $responseid]); + } +} diff --git a/classes/local/db/response_multiple_record.php b/classes/local/db/response_multiple_record.php new file mode 100644 index 000000000..a56890ad7 --- /dev/null +++ b/classes/local/db/response_multiple_record.php @@ -0,0 +1,65 @@ +. + +namespace mod_questionnaire\local\db; + +/** + * Persistent record for the questionnaire_resp_multiple table. + * + * Each row represents one selected choice in a multiple-choice (checkbox) question. + * Multiple rows per response/question are expected. + * + * @package mod_questionnaire + * @copyright 2016 Mike Churchward (mike.churchward@poetopensource.org) + * @author Mike Churchward + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class response_multiple_record extends \core\persistent { + /** @var string The table name. */ + public const TABLE = 'questionnaire_resp_multiple'; + + /** + * Define the properties of this record. + * + * @return array + */ + protected static function define_properties(): array { + return [ + 'responseid' => [ + 'type' => PARAM_INT, + 'default' => 0, + ], + 'questionid' => [ + 'type' => PARAM_INT, + 'default' => 0, + ], + 'choiceid' => [ + 'type' => PARAM_INT, + 'default' => 0, + ], + ]; + } + + /** + * Return all multiple-choice answers for a given response. + * + * @param int $responseid + * @return response_multiple_record[] + */ + public static function get_for_response(int $responseid): array { + return static::get_records(['responseid' => $responseid]); + } +} diff --git a/classes/local/db/response_other_record.php b/classes/local/db/response_other_record.php new file mode 100644 index 000000000..ee6c69fb0 --- /dev/null +++ b/classes/local/db/response_other_record.php @@ -0,0 +1,69 @@ +. + +namespace mod_questionnaire\local\db; + +/** + * Persistent record for the questionnaire_response_other table. + * + * Stores the free-text value entered in an "Other..." option of a choice question. + * + * @package mod_questionnaire + * @copyright 2016 Mike Churchward (mike.churchward@poetopensource.org) + * @author Mike Churchward + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class response_other_record extends \core\persistent { + /** @var string The table name. */ + public const TABLE = 'questionnaire_response_other'; + + /** + * Define the properties of this record. + * + * @return array + */ + protected static function define_properties(): array { + return [ + 'responseid' => [ + 'type' => PARAM_INT, + 'default' => 0, + ], + 'questionid' => [ + 'type' => PARAM_INT, + 'default' => 0, + ], + 'choiceid' => [ + 'type' => PARAM_INT, + 'default' => 0, + ], + 'response' => [ + 'type' => PARAM_TEXT, + 'null' => NULL_ALLOWED, + 'default' => null, + ], + ]; + } + + /** + * Return all "other" answers for a given response. + * + * @param int $responseid + * @return response_other_record[] + */ + public static function get_for_response(int $responseid): array { + return static::get_records(['responseid' => $responseid]); + } +} diff --git a/classes/local/db/response_rank_record.php b/classes/local/db/response_rank_record.php new file mode 100644 index 000000000..0b99e443d --- /dev/null +++ b/classes/local/db/response_rank_record.php @@ -0,0 +1,100 @@ +. + +namespace mod_questionnaire\local\db; + +/** + * Persistent record for the questionnaire_response_rank table. + * + * Each row holds the rank value assigned to one choice in a rate/rank question. + * + * @package mod_questionnaire + * @copyright 2016 Mike Churchward (mike.churchward@poetopensource.org) + * @author Mike Churchward + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class response_rank_record extends \core\persistent { + /** @var string The table name. */ + public const TABLE = 'questionnaire_response_rank'; + + /** + * Define the properties of this record. + * + * @return array + */ + protected static function define_properties(): array { + return [ + 'responseid' => [ + 'type' => PARAM_INT, + 'default' => 0, + ], + 'questionid' => [ + 'type' => PARAM_INT, + 'default' => 0, + ], + 'choiceid' => [ + 'type' => PARAM_INT, + 'default' => 0, + ], + 'rankvalue' => [ + 'type' => PARAM_INT, + 'default' => 0, + ], + ]; + } + + /** + * Return all rank answers for a given response. + * + * @param int $responseid + * @return response_rank_record[] + */ + public static function get_for_response(int $responseid): array { + return static::get_records(['responseid' => $responseid]); + } + + /** + * Return all rank answers for a given question. + * + * @param int $questionid + * @return response_rank_record[] + */ + public static function get_for_question(int $questionid): array { + return static::get_records(['questionid' => $questionid]); + } + + /** + * Bulk-increment every non-negative rankvalue by 1, optionally restricted to a set of questions. + * + * Used by the one-time upgrade routine that converted zero-based rank values to one-based. + * + * @param int[]|null $questionids Restrict to these question ids; null applies to all questions. + * @return bool + */ + public static function increment_rankvalues(?array $questionids = null): bool { + global $DB; + $sql = 'UPDATE {' . static::TABLE . '} SET rankvalue = (rankvalue + 1) WHERE rankvalue >= 0'; + $params = []; + if ($questionids !== null) { + if (empty($questionids)) { + return true; + } + [$insql, $params] = $DB->get_in_or_equal($questionids); + $sql .= ' AND questionid ' . $insql; + } + return $DB->execute($sql, $params); + } +} diff --git a/classes/local/db/response_record.php b/classes/local/db/response_record.php new file mode 100644 index 000000000..8901902c7 --- /dev/null +++ b/classes/local/db/response_record.php @@ -0,0 +1,254 @@ +. + +namespace mod_questionnaire\local\db; + +/** + * Persistent record for the questionnaire_response table. + * + * Each row represents a single attempt (complete or in-progress) by a user. + * The 'complete' field uses 'y'/'n' rather than a boolean integer. + * + * @package mod_questionnaire + * @copyright 2016 Mike Churchward (mike.churchward@poetopensource.org) + * @author Mike Churchward + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class response_record extends \core\persistent { + /** @var string The table name. */ + public const TABLE = 'questionnaire_response'; + + /** + * Define the properties of this record. + * + * @return array + */ + protected static function define_properties(): array { + return [ + 'questionnaireid' => [ + 'type' => PARAM_INT, + 'default' => 0, + ], + 'submitted' => [ + 'type' => PARAM_INT, + 'default' => 0, + ], + 'complete' => [ + 'type' => PARAM_ALPHA, + 'default' => 'n', + ], + 'grade' => [ + 'type' => PARAM_INT, + 'default' => 0, + ], + 'userid' => [ + 'type' => PARAM_INT, + 'null' => NULL_ALLOWED, + 'default' => null, + ], + ]; + } + + /** + * Return all responses for a given questionnaire instance. + * + * @param int $questionnaireid + * @return response_record[] + */ + public static function get_for_questionnaire(int $questionnaireid): array { + return static::get_records(['questionnaireid' => $questionnaireid]); + } + + /** + * Return the response with the given id, or null if it does not exist. + * + * Persistent's constructor throws on a missing id; callers that want to + * branch on "exists or not" should use this finder instead. + * + * @param int $rid + * @return response_record|null + */ + public static function get_or_null(int $rid): ?self { + if (empty($rid)) { + return null; + } + return static::record_exists($rid) ? new self($rid) : null; + } + + /** + * Return all complete responses for a given questionnaire and user. + * + * @param int $questionnaireid + * @param int $userid + * @return response_record[] + */ + public static function get_complete_for_user(int $questionnaireid, int $userid): array { + return static::get_records(['questionnaireid' => $questionnaireid, 'userid' => $userid, 'complete' => 'y']); + } + + /** + * True if the given user has at least one complete response for the given questionnaire. + * + * @param int $questionnaireid + * @param int $userid + * @return bool + */ + public static function user_has_complete_response(int $questionnaireid, int $userid): bool { + return static::record_exists_select( + 'questionnaireid = :questionnaireid AND userid = :userid AND complete = :complete', + ['questionnaireid' => $questionnaireid, 'userid' => $userid, 'complete' => 'y'] + ); + } + + /** + * True if the given user has an in-progress (incomplete) saved response for the given questionnaire. + * + * @param int $questionnaireid + * @param int $userid + * @return bool + */ + public static function user_has_saved_response(int $questionnaireid, int $userid): bool { + return static::record_exists_select( + 'questionnaireid = :questionnaireid AND userid = :userid AND complete = :complete', + ['questionnaireid' => $questionnaireid, 'userid' => $userid, 'complete' => 'n'] + ); + } + + /** + * Count complete responses for one questionnaire instance. + * + * @param int $questionnaireid Restrict to this instance. + * @param int|false $userid Restrict to a specific user, or false for all users. + * @param int $groupid Restrict to members of this group, or 0 for no group filter. + * @return int + */ + public static function count_complete_for_questionnaire( + int $questionnaireid, + $userid = false, + int $groupid = 0 + ): int { + global $DB; + + $params = ['questionnaireid' => $questionnaireid, 'status' => 'y']; + $sql = 'SELECT COUNT(r.id) + FROM {questionnaire_response} r '; + if ($groupid != 0) { + $sql .= 'INNER JOIN {groups_members} gm ON r.userid = gm.userid '; + } + $sql .= 'WHERE r.questionnaireid = :questionnaireid AND r.complete = :status'; + if ($groupid != 0) { + $sql .= ' AND gm.groupid = :groupid'; + $params['groupid'] = $groupid; + } + if ($userid) { + $sql .= ' AND r.userid = :userid'; + $params['userid'] = $userid; + } + return $DB->count_records_sql($sql, $params); + } + + /** + * Count complete responses across every questionnaire instance that points at the given (public) survey. + * + * @param int $surveyid The public-master survey id. + * @param int|false $userid Restrict to a specific user, or false for all users. + * @param int $groupid Restrict to members of this group, or 0 for no group filter. + * @return int + */ + public static function count_complete_for_public_survey( + int $surveyid, + $userid = false, + int $groupid = 0 + ): int { + global $DB; + + $params = ['surveyid' => $surveyid, 'status' => 'y']; + $sql = 'SELECT COUNT(r.id) + FROM {questionnaire_response} r + INNER JOIN {questionnaire} q ON r.questionnaireid = q.id + INNER JOIN {questionnaire_survey} s ON q.sid = s.id '; + if ($groupid != 0) { + $sql .= 'INNER JOIN {groups_members} gm ON r.userid = gm.userid '; + } + $sql .= 'WHERE s.id = :surveyid AND r.complete = :status'; + if ($groupid != 0) { + $sql .= ' AND gm.groupid = :groupid'; + $params['groupid'] = $groupid; + } + if ($userid) { + $sql .= ' AND r.userid = :userid'; + $params['userid'] = $userid; + } + return $DB->count_records_sql($sql, $params); + } + + /** + * Return the course fullname for the questionnaire that owns the given complete response, or null. + * + * Used when displaying respondent info for a response submitted against a public survey, + * where the response may belong to a questionnaire instance in a different course. + * + * @param int $rid Response id. + * @return string|null + */ + public static function get_course_fullname(int $rid): ?string { + global $DB; + + $sql = 'SELECT c.fullname + FROM {questionnaire_response} r + INNER JOIN {questionnaire} q ON r.questionnaireid = q.id + INNER JOIN {course} c ON q.course = c.id + WHERE r.id = :rid AND r.complete = :status'; + $fullname = $DB->get_field_sql($sql, ['rid' => $rid, 'status' => 'y']); + return $fullname === false ? null : $fullname; + } + + /** + * Return the most recent incomplete response record for the given questionnaire and user, or null. + * + * @param int $questionnaireid + * @param int $userid + * @return response_record|null + */ + public static function get_latest_incomplete(int $questionnaireid, int $userid): ?self { + $records = static::get_records( + ['questionnaireid' => $questionnaireid, 'userid' => $userid, 'complete' => 'n'], + 'submitted', + 'DESC', + 0, + 1 + ); + return empty($records) ? null : reset($records); + } + + /** + * Return the most recent complete response record for the given questionnaire and user, or null. + * + * @param int $questionnaireid + * @param int $userid + * @return response_record|null + */ + public static function get_latest_complete_for_user(int $questionnaireid, int $userid): ?self { + $records = static::get_records( + ['questionnaireid' => $questionnaireid, 'userid' => $userid, 'complete' => 'y'], + 'submitted', + 'DESC', + 0, + 1 + ); + return empty($records) ? null : reset($records); + } +} diff --git a/classes/local/db/response_single_record.php b/classes/local/db/response_single_record.php new file mode 100644 index 000000000..361b8ae29 --- /dev/null +++ b/classes/local/db/response_single_record.php @@ -0,0 +1,64 @@ +. + +namespace mod_questionnaire\local\db; + +/** + * Persistent record for the questionnaire_resp_single table. + * + * Stores the single selected choice for a radio-button question. + * + * @package mod_questionnaire + * @copyright 2016 Mike Churchward (mike.churchward@poetopensource.org) + * @author Mike Churchward + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class response_single_record extends \core\persistent { + /** @var string The table name. */ + public const TABLE = 'questionnaire_resp_single'; + + /** + * Define the properties of this record. + * + * @return array + */ + protected static function define_properties(): array { + return [ + 'responseid' => [ + 'type' => PARAM_INT, + 'default' => 0, + ], + 'questionid' => [ + 'type' => PARAM_INT, + 'default' => 0, + ], + 'choiceid' => [ + 'type' => PARAM_INT, + 'default' => 0, + ], + ]; + } + + /** + * Return all single-choice answers for a given response. + * + * @param int $responseid + * @return response_single_record[] + */ + public static function get_for_response(int $responseid): array { + return static::get_records(['responseid' => $responseid]); + } +} diff --git a/classes/local/db/response_text_record.php b/classes/local/db/response_text_record.php new file mode 100644 index 000000000..e48d86071 --- /dev/null +++ b/classes/local/db/response_text_record.php @@ -0,0 +1,66 @@ +. + +namespace mod_questionnaire\local\db; + +/** + * Persistent record for the questionnaire_response_text table. + * + * Stores free-text answers. PARAM_RAW is used because responses may contain + * formatted content (HTML, equations, etc.) entered by the respondent. + * + * @package mod_questionnaire + * @copyright 2016 Mike Churchward (mike.churchward@poetopensource.org) + * @author Mike Churchward + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class response_text_record extends \core\persistent { + /** @var string The table name. */ + public const TABLE = 'questionnaire_response_text'; + + /** + * Define the properties of this record. + * + * @return array + */ + protected static function define_properties(): array { + return [ + 'responseid' => [ + 'type' => PARAM_INT, + 'default' => 0, + ], + 'questionid' => [ + 'type' => PARAM_INT, + 'default' => 0, + ], + 'response' => [ + 'type' => PARAM_RAW, + 'null' => NULL_ALLOWED, + 'default' => null, + ], + ]; + } + + /** + * Return all text answers for a given response. + * + * @param int $responseid + * @return response_text_record[] + */ + public static function get_for_response(int $responseid): array { + return static::get_records(['responseid' => $responseid]); + } +} diff --git a/classes/local/db/survey_record.php b/classes/local/db/survey_record.php new file mode 100644 index 000000000..7c63c9a4a --- /dev/null +++ b/classes/local/db/survey_record.php @@ -0,0 +1,177 @@ +. + +namespace mod_questionnaire\local\db; + +/** + * Persistent record for the questionnaire_survey table. + * + * The survey is the content container for a questionnaire. A survey can be + * shared across multiple questionnaire activity instances (public/template surveys). + * + * @package mod_questionnaire + * @copyright 2016 Mike Churchward (mike.churchward@poetopensource.org) + * @author Mike Churchward + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class survey_record extends \core\persistent { + /** @var string The table name. */ + public const TABLE = 'questionnaire_survey'; + + /** + * Define the properties of this record. + * + * @return array + */ + protected static function define_properties(): array { + return [ + 'name' => [ + 'type' => PARAM_TEXT, + ], + 'courseid' => [ + 'type' => PARAM_INT, + 'null' => NULL_ALLOWED, + 'default' => null, + ], + 'realm' => [ + 'type' => PARAM_ALPHA, + 'default' => '', + ], + 'status' => [ + 'type' => PARAM_INT, + 'default' => 0, + ], + 'title' => [ + 'type' => PARAM_TEXT, + 'default' => '', + ], + 'email' => [ + 'type' => PARAM_TEXT, + 'null' => NULL_ALLOWED, + 'default' => null, + ], + 'subtitle' => [ + 'type' => PARAM_RAW, + 'null' => NULL_ALLOWED, + 'default' => null, + ], + 'info' => [ + 'type' => PARAM_RAW, + 'null' => NULL_ALLOWED, + 'default' => null, + ], + 'theme' => [ + 'type' => PARAM_TEXT, + 'null' => NULL_ALLOWED, + 'default' => null, + ], + 'thankspage' => [ + 'type' => PARAM_URL, + 'null' => NULL_ALLOWED, + 'default' => null, + ], + 'thankhead' => [ + 'type' => PARAM_TEXT, + 'null' => NULL_ALLOWED, + 'default' => null, + ], + 'thankbody' => [ + 'type' => PARAM_RAW, + 'null' => NULL_ALLOWED, + 'default' => null, + ], + 'feedbacksections' => [ + 'type' => PARAM_INT, + 'null' => NULL_ALLOWED, + 'default' => 0, + ], + 'feedbacknotes' => [ + 'type' => PARAM_RAW, + 'null' => NULL_ALLOWED, + 'default' => null, + ], + 'feedbackscores' => [ + 'type' => PARAM_INT, + 'null' => NULL_ALLOWED, + 'default' => 0, + ], + 'charttype' => [ + 'type' => PARAM_ALPHA, + 'null' => NULL_ALLOWED, + 'default' => null, + ], + ]; + } + + /** + * Return true if a survey record with the given id exists in the database. + * + * @param int $id + * @return bool + */ + public static function record_exists($id): bool { + return static::record_exists_select('id = ?', [$id]); + } + + /** + * Return all surveys with the given realm belonging to a specific course. + * + * @param string $realm + * @param int $courseid + * @return self[] + */ + public static function get_by_realm_in_course(string $realm, int $courseid): array { + return static::get_records(['realm' => $realm, 'courseid' => $courseid], 'name'); + } + + /** + * Return all surveys with the given realm across all courses. + * + * @param string $realm + * @return self[] + */ + public static function get_by_realm(string $realm): array { + return static::get_records(['realm' => $realm], 'name'); + } + + /** + * Return every survey that has no linked questionnaire instance row. + * + * Used by the cleanup task to garbage-collect surveys whose questionnaire + * was deleted without taking the survey with it (because the survey was + * shared at the time, or because of a partial-delete bug in older versions). + * + * @return self[] + */ + public static function get_orphaned(): array { + global $DB; + $sql = 'SELECT qs.* FROM {' . static::TABLE . '} qs + LEFT JOIN {questionnaire} q ON q.sid = qs.id + WHERE q.sid IS NULL'; + $records = $DB->get_records_sql($sql); + return array_map(fn($r) => new static(0, $r), $records); + } + + /** + * Create a new survey record from survey data. + * + * @param stdClass $sdata Survey data object. + * @return self The created record. + */ + public static function create_from_sdata(\stdClass $sdata): self { + return (new self(0, $sdata))->create(); + } +} diff --git a/classes/local/feedback/chart_renderer.php b/classes/local/feedback/chart_renderer.php new file mode 100644 index 000000000..4b88ad1c3 --- /dev/null +++ b/classes/local/feedback/chart_renderer.php @@ -0,0 +1,764 @@ +. + +/** + * RGraph chart-rendering helper for the feedback scoreboard. + * + * @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 + */ + +namespace mod_questionnaire\local\feedback; + +/** + * Builds the canvas markup + RGraph spec consumed by feedback::build_scoreboard(). + * + * Emits one or two elements with unique ids, registers the RGraph + * third-party scripts on the page, and queues the mod_questionnaire/chart AMD + * module with a declarative spec describing the chart(s) to draw. The AMD + * module then constructs the RGraph objects and applies the spec's Set() ops. + * + * Data preparation (label padding, color choice, dimensions, RGraph option + * values) stays in PHP so we don't have to reimplement core_text-aware + * unicode handling in the JS module. + */ +class chart_renderer { + /** @var string[] Gradient color sequence used by hbar charts. */ + private const CHART_COLORS_GRADIENT = [ + '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)', + ]; + + /** @var string[] Gradient color sequence used by rose charts. */ + private const CHART_COLORS_ROSE = [ + 'Gradient(white:red)', 'Gradient(white:green)', 'Gradient(white:blue)', + 'Gradient(white:gray)', 'Gradient(white:purple)', 'Gradient(white:pink)', + 'Gradient(white:orange)', 'Gradient(white:black)', + ]; + + /** @var array charttype => RGraph constructor name. */ + private const RGRAPH_CTOR = [ + 'bipolar' => 'Bipolar', + 'hbar' => 'HBar', + 'radar' => 'Radar', + 'rose' => 'Rose', + 'vprogress' => 'VProgress', + ]; + + /** @var array charttype => filename in javascript/RGraph/ that defines the constructor. */ + private const RGRAPH_SCRIPT = [ + 'bipolar' => 'RGraph.bipolar.js', + 'hbar' => 'RGraph.hbar.js', + 'radar' => 'RGraph.radar.js', + 'rose' => 'RGraph.rose.js', + 'vprogress' => 'RGraph.vprogress.js', + ]; + + /** @var int Monotonic counter for unique canvas ids within a request. */ + private static int $counter = 0; + + /** + * Render an RGraph chart for the feedback scoreboard. + * + * Builds canvas markup, registers the RGraph third-party scripts on $page, + * and queues mod_questionnaire/chart::render via $PAGE->requires->js_call_amd + * with a declarative spec describing the chart(s) to draw. + * + * @param \moodle_page $page The page to register JS requirements on (usually $PAGE). + * @param string $feedbacktype 'global' or 'sections'. + * @param array $labels Chart axis labels (per-section labels, or [] for global). + * @param string $groupname Current group name shown in chart title 2. + * @param bool $allresponses True when rendering all responses (vs. compare-one-to-group). + * @param string|null $charttype 'bipolar', 'hbar', 'radar', 'rose', 'vprogress'. + * @param array|null $score The "this response" series. + * @param array|null $allscore The "group" series. + * @param string|null $globallabel Label used in 'global' feedbacktype mode. + * @param string $charttitle First-chart title text (e.g. "Your response"). + * @return string Canvas HTML ready to be inserted into the scoreboard. + */ + public static function render( + \moodle_page $page, + $feedbacktype, + $labels, + $groupname, + $allresponses, + $charttype = null, + $score = null, + $allscore = null, + $globallabel = null, + $charttitle = '' + ): string { + if (!isset(self::RGRAPH_CTOR[$charttype])) { + return ''; + } + + $charttitle2 = $groupname; + $charttitlefont = 'Verdana'; + $charttitlesize = 10; + + if ($allresponses) { + $nbvalues = count($allscore); + } else { + $nbvalues = count($score); + } + $nblabels = count($labels); + + if ($feedbacktype == 'global') { + $labels = [$globallabel]; + } + + $base = 'questionnaire-chart-' . (++self::$counter); + $canvasidprimary = $base . '-primary'; + $canvasidsecondary = $base . '-secondary'; + + $primary = null; + $secondary = null; + $primaryhtml = ''; + $secondaryhtml = ''; + + switch ($charttype) { + case 'bipolar': + [$primary, $secondary, $primaryhtml, $secondaryhtml] = self::build_bipolar( + $canvasidprimary, + $canvasidsecondary, + $feedbacktype, + $labels, + $charttitle, + $charttitle2, + $charttitlefont, + $charttitlesize, + $allresponses, + $score, + $allscore, + $nbvalues, + $nblabels + ); + break; + + case 'hbar': + [$primary, $secondary, $primaryhtml, $secondaryhtml] = self::build_hbar( + $canvasidprimary, + $canvasidsecondary, + $feedbacktype, + $labels, + $globallabel, + $charttitle, + $charttitle2, + $charttitlefont, + $charttitlesize, + $allresponses, + $score, + $allscore, + $nbvalues, + $nblabels + ); + break; + + case 'radar': + [$primary, $secondary, $primaryhtml, $secondaryhtml] = self::build_radar( + $canvasidprimary, + $canvasidsecondary, + $labels, + $charttitle, + $charttitle2, + $allresponses, + $score, + $allscore + ); + break; + + case 'rose': + [$primary, $secondary, $primaryhtml, $secondaryhtml] = self::build_rose( + $canvasidprimary, + $canvasidsecondary, + $labels, + $charttitle, + $charttitle2, + $charttitlefont, + $charttitlesize, + $allresponses, + $score, + $allscore, + $nblabels + ); + break; + + case 'vprogress': + [$primary, $secondary, $primaryhtml, $secondaryhtml] = self::build_vprogress( + $canvasidprimary, + $canvasidsecondary, + $globallabel, + $charttitle, + $charttitle2, + $charttitlefont, + $charttitlesize, + $allresponses, + $score, + $allscore + ); + break; + } + + $charts = []; + if ($primary !== null) { + $charts[] = $primary; + } + if ($secondary !== null) { + $charts[] = $secondary; + } + if (empty($charts)) { + return ''; + } + + self::queue_rgraph($page, $charttype); + $page->requires->js_call_amd('mod_questionnaire/chart', 'render', [['charts' => $charts]]); + + return $primaryhtml . $secondaryhtml; + } + + // Private helpers. + + /** + * Register the common RGraph script + the charttype-specific RGraph script on $page. + * + * Loaded via $page->requires->js() (not AMD) because the RGraph library is a + * third-party global that the AMD chart driver reads from window.RGraph. + * + * @param \moodle_page $page + * @param string $charttype + */ + private static function queue_rgraph(\moodle_page $page, string $charttype): void { + $page->requires->js('/mod/questionnaire/javascript/RGraph/RGraph.common.core.js'); + $page->requires->js('/mod/questionnaire/javascript/RGraph/' . self::RGRAPH_SCRIPT[$charttype]); + } + + /** + * Construct a single chart spec entry. + * + * @param string $type RGraph constructor name (e.g. 'Bipolar'). + * @param string $canvasid + * @param array $ctorargs Additional positional args passed after canvasId. + * @param array $sets List of [key, value] pairs for chart.Set() calls. + * @return array + */ + private static function spec(string $type, string $canvasid, array $ctorargs, array $sets): array { + return [ + 'canvasId' => $canvasid, + 'type' => $type, + 'args' => $ctorargs, + 'sets' => $sets, + ]; + } + + /** + * Emit a tag with the given dimensions. + * + * @param string $id + * @param int $width + * @param int $height + * @return string + */ + private static function canvas(string $id, int $width, int $height): string { + return '[No canvas support]'; + } + + /** + * Build the bipolar chart primary/secondary specs and canvases. + * + * @param string $canvasidprimary + * @param string $canvasidsecondary + * @param string $feedbacktype + * @param array $labels + * @param string $charttitle + * @param string $charttitle2 + * @param string $charttitlefont + * @param int $charttitlesize + * @param bool $allresponses + * @param array|null $score + * @param array|null $allscore + * @param int $nbvalues + * @param int $nblabels + * @return array [primary spec|null, secondary spec|null, primary html, secondary html] + */ + private static function build_bipolar( + string $canvasidprimary, + string $canvasidsecondary, + string $feedbacktype, + array $labels, + string $charttitle, + string $charttitle2, + string $charttitlefont, + int $charttitlesize, + bool $allresponses, + ?array $score, + ?array $allscore, + int $nbvalues, + int $nblabels + ): array { + $oppositescore = null; + $alloppositescore = null; + + if ($feedbacktype == 'global') { + if ($score) { + if ($allresponses) { + $score = null; + } else { + $original = $score; + $score = [$original[0]]; + $oppositescore = [$original[1]]; + } + } + if ($allscore) { + $original = $allscore; + $allscore = [$original[0]]; + $alloppositescore = [$original[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; + } + } + } + + // Pad each label so the left and right segments line up around the pipe separator. + foreach ($labels as $key => $label) { + $lb = explode('|', $label); + if (count($lb) > 1) { + $left = $lb[0]; + $right = $lb[1]; + $lenleft = \core_text::strlen($left); + $diffleft = strlen($left) - $lenleft; + $lenright = \core_text::strlen($right); + $diffright = strlen($right) - $lenright; + if ($lenleft < $lenright) { + $left = str_pad($left, $lenright + $diffleft, ' ', STR_PAD_LEFT); + } + if ($lenleft > $lenright) { + $right = str_pad($right, $lenleft + $diffright, ' ', STR_PAD_RIGHT); + } + $labels[$key] = $left . ' ' . $right; + } + } + $maxlen = 0; + foreach ($labels as $label) { + $maxlen = max($maxlen, \core_text::strlen($label)); + } + + $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'; + } + } + + $canvasheight = (int)(($nblabels * 25) + 60); + $canvaswidth = max(300, (int)(100 + ($maxlen * 7))); + + $primary = null; + $primaryhtml = ''; + if (!$allresponses && $score) { + $primaryhtml = self::canvas($canvasidprimary, $canvaswidth, $canvasheight); + $primary = self::spec('Bipolar', $canvasidprimary, [$score, $oppositescore], [ + ['chart.title', $charttitle], + ['chart.title.font', $charttitlefont], + ['chart.title.size', (string)$charttitlesize], + ['chart.labels', array_values($labels)], + ['chart.gutter.center', 0], + ['chart.gutter.left', 10], + ['chart.gutter.top', 40], + ['chart.gutter.bottom', 20], + ['chart.xmax', 100], + ['chart.text.size', 10], + ['chart.text.font', 'Courier'], + ['chart.colors', $chartcolors], + ['chart.colors.sequential', true], + ]); + } + + $secondary = null; + $secondaryhtml = ''; + if ($allscore) { + $secondaryhtml = self::canvas($canvasidsecondary, $canvaswidth, $canvasheight); + $secondary = self::spec('Bipolar', $canvasidsecondary, [$allscore, $alloppositescore], [ + ['chart.title', $charttitle2], + ['chart.title.font', $charttitlefont], + ['chart.title.size', (string)$charttitlesize], + ['chart.labels', array_values($labels)], + ['chart.gutter.center', 0], + ['chart.gutter.left', 10], + ['chart.gutter.right', 15], + ['chart.gutter.top', 40], + ['chart.gutter.bottom', 20], + ['chart.xmax', 100], + ['chart.text.size', 10], + ['chart.text.font', 'Courier'], + ['chart.colors', $chartcolors2], + ['chart.colors.sequential', true], + ]); + } + + return [$primary, $secondary, $primaryhtml, $secondaryhtml]; + } + + /** + * Build the hbar chart primary/secondary specs and canvases. + * + * @param string $canvasidprimary + * @param string $canvasidsecondary + * @param string $feedbacktype + * @param array $labels + * @param string|null $globallabel + * @param string $charttitle + * @param string $charttitle2 + * @param string $charttitlefont + * @param int $charttitlesize + * @param bool $allresponses + * @param array|null $score + * @param array|null $allscore + * @param int $nbvalues + * @param int $nblabels + * @return array [primary spec|null, secondary spec|null, primary html, secondary html] + */ + private static function build_hbar( + string $canvasidprimary, + string $canvasidsecondary, + string $feedbacktype, + array $labels, + ?string $globallabel, + string $charttitle, + string $charttitle2, + string $charttitlefont, + int $charttitlesize, + bool $allresponses, + ?array $score, + ?array $allscore, + int $nbvalues, + int $nblabels + ): array { + $sequential = true; + + if ($feedbacktype == 'global') { + $lb = explode('|', (string)$globallabel); + if (count($lb) > 1) { + $labels = []; + $left = $lb[0]; + $right = $lb[1]; + $lenleft = \core_text::strlen($left); + $lenright = \core_text::strlen($right); + if ($lenleft < $lenright) { + $left = str_pad($left, $lenright, ' ', STR_PAD_LEFT); + } + if ($lenleft > $lenright) { + $right = str_pad($right, $lenleft, ' ', 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; + + $maxlen = 0; + foreach ($labels as $label) { + $maxlen = max($maxlen, \core_text::strlen($label)); + } + $labels = array_values($labels); + + $canvasheight = (int)(($nblabels * 20) + 60); + $gutterleft = (int)(($maxlen * 8) + 5); + $canvaswidth = 400 + $gutterleft; + + $commonsets = function (string $title) use ($labels, $charttitlefont, $charttitlesize, $gutterleft, $sequential): array { + return [ + ['chart.title', $title], + ['chart.title.font', $charttitlefont], + ['chart.title.size', (string)$charttitlesize], + ['chart.title.x', 400], + ['gutter.left', (string)$gutterleft], + ['gutter.right', 2], + ['chart.text.font', 'Courier'], + ['labels', $labels], + ['chart.colors', self::CHART_COLORS_GRADIENT], + ['chart.colors.sequential', $sequential], + ['xmax', 100], + ]; + }; + + $primary = null; + $primaryhtml = ''; + if (!$allresponses && $score) { + $primaryhtml = self::canvas($canvasidprimary, $canvaswidth, $canvasheight); + $primary = self::spec('HBar', $canvasidprimary, [$score], $commonsets($charttitle)); + } + + $secondary = null; + $secondaryhtml = ''; + if ($allscore) { + $secondaryhtml = self::canvas($canvasidsecondary, $canvaswidth, $canvasheight); + $secondary = self::spec('HBar', $canvasidsecondary, [$allscore], $commonsets($charttitle2)); + } + + return [$primary, $secondary, $primaryhtml, $secondaryhtml]; + } + + /** + * Build the radar chart primary/secondary specs and canvases. + * + * @param string $canvasidprimary + * @param string $canvasidsecondary + * @param array $labels + * @param string $charttitle + * @param string $charttitle2 + * @param bool $allresponses + * @param array|null $score + * @param array|null $allscore + * @return array [primary spec|null, secondary spec|null, primary html, secondary html] + */ + private static function build_radar( + string $canvasidprimary, + string $canvasidsecondary, + array $labels, + string $charttitle, + string $charttitle2, + bool $allresponses, + ?array $score, + ?array $allscore + ): array { + foreach ($labels as $key => $label) { + if ($key != 0) { + $labels[$key] = wordwrap($label, 20, "\r\n"); + } else { + $labels[$key] = $label . "\r\n"; + } + } + $labels = array_values($labels); + + $commonsets = function (string $title) use ($labels): array { + return [ + ['chart.title', $title], + ['chart.labels', $labels], + ['chart.labels.offset', 15], + ['chart.radius', 150], + ['chart.ymax', 100], + ['chart.labels.axes', 'n'], + ]; + }; + + $primary = null; + $primaryhtml = ''; + if (!$allresponses && $score) { + $primaryhtml = self::canvas($canvasidprimary, 550, 400); + $primary = self::spec('Radar', $canvasidprimary, [$score], $commonsets($charttitle)); + } + + $secondary = null; + $secondaryhtml = ''; + if ($allscore) { + $secondaryhtml = self::canvas($canvasidsecondary, 550, 400); + $secondary = self::spec('Radar', $canvasidsecondary, [$allscore], $commonsets($charttitle2)); + } + + return [$primary, $secondary, $primaryhtml, $secondaryhtml]; + } + + /** + * Build the rose chart primary/secondary specs and canvases. + * + * @param string $canvasidprimary + * @param string $canvasidsecondary + * @param array $labels + * @param string $charttitle + * @param string $charttitle2 + * @param string $charttitlefont + * @param int $charttitlesize + * @param bool $allresponses + * @param array|null $score + * @param array|null $allscore + * @param int $nblabels + * @return array [primary spec|null, secondary spec|null, primary html, secondary html] + */ + private static function build_rose( + string $canvasidprimary, + string $canvasidsecondary, + array $labels, + string $charttitle, + string $charttitle2, + string $charttitlefont, + int $charttitlesize, + bool $allresponses, + ?array $score, + ?array $allscore, + int $nblabels + ): array { + foreach ($labels as $key => $label) { + $labels[$key] = wordwrap($label, 8, "\r\n"); + } + $labels = array_values($labels); + + $commonsets = function (string $title) use ($labels, $charttitlefont, $charttitlesize, $nblabels): array { + return [ + ['chart.title', $title], + ['chart.title.font', $charttitlefont], + ['chart.title.size', (string)$charttitlesize], + ['chart.title.vpos', 0.2], + ['chart.labels', $labels], + ['chart.labels.offset', 10], + ['chart.background.grid.spokes', $nblabels], + ['chart.labels.axes', 'n'], + ['chart.radius', 100], + ['chart.ymax', 100], + ['chart.background.axes', false], + ['chart.colors.sequential', true], + ['chart.colors', self::CHART_COLORS_ROSE], + ]; + }; + + $primary = null; + $primaryhtml = ''; + if (!$allresponses && $score) { + $primaryhtml = self::canvas($canvasidprimary, 400, 400); + $primary = self::spec('Rose', $canvasidprimary, [$score], $commonsets($charttitle)); + } + + $secondary = null; + $secondaryhtml = ''; + if ($allscore) { + // The original PHP injected three   characters between the two rose canvases. + $secondaryhtml = '   ' . self::canvas($canvasidsecondary, 400, 400); + $secondary = self::spec('Rose', $canvasidsecondary, [$allscore], $commonsets($charttitle2)); + } + + return [$primary, $secondary, $primaryhtml, $secondaryhtml]; + } + + /** + * Build the vprogress chart primary/secondary specs and canvases. + * + * @param string $canvasidprimary + * @param string $canvasidsecondary + * @param string|null $globallabel + * @param string $charttitle + * @param string $charttitle2 + * @param string $charttitlefont + * @param int $charttitlesize + * @param bool $allresponses + * @param array|null $score + * @param array|null $allscore + * @return array [primary spec|null, secondary spec|null, primary html, secondary html] + */ + private static function build_vprogress( + string $canvasidprimary, + string $canvasidsecondary, + ?string $globallabel, + string $charttitle, + string $charttitle2, + string $charttitlefont, + int $charttitlesize, + bool $allresponses, + ?array $score, + ?array $allscore + ): array { + $primaryscore = (!$allresponses && $score) ? $score[0] : null; + $secondaryscore = $allscore ? $allscore[0] : null; + + // Check presence of pipe separator in label. + $labels = []; + $maxlen = 0; + $lb = explode('|', (string)$globallabel); + if (count($lb) > 1) { + $labels = array_reverse($lb); + foreach ($labels as $label) { + $maxlen = max($maxlen, \core_text::strlen($label)); + } + } + + $gutterright = 150 + ($maxlen * 3); + $canvaswidth = 250 + ($maxlen * 3); + + $primary = null; + $primaryhtml = ''; + if (!$allresponses && $primaryscore !== null) { + $primaryhtml = self::canvas($canvasidprimary, $canvaswidth, 400); + $primarysets = [ + ['chart.gutter.top', 30], + ['chart.gutter.left', 50], + ['chart.gutter.right', (string)$gutterright], + ['scale.decimals', 0], + ['chart.text.font', 'Courier'], + ['chart.title', $charttitle], + ['chart.title.font', $charttitlefont], + ['chart.title.size', (string)$charttitlesize], + ]; + if (!empty($labels)) { + $primarysets[] = ['chart.labels.specific', $labels]; + } + $primary = self::spec('VProgress', $canvasidprimary, [$primaryscore, 100], $primarysets); + } + + $secondary = null; + $secondaryhtml = ''; + if ($secondaryscore !== null) { + $secondaryhtml = self::canvas($canvasidsecondary, 250, 400); + $secondary = self::spec('VProgress', $canvasidsecondary, [$secondaryscore, 100], [ + ['chart.gutter.top', 30], + ['chart.gutter.left', 50], + ['chart.gutter.right', 150], + ['chart.title', $charttitle2], + ['chart.text.font', 'Courier'], + ['chart.title.font', $charttitlefont], + ['chart.title.size', (string)$charttitlesize], + ['scale.decimals', 0], + ]); + } + + return [$primary, $secondary, $primaryhtml, $secondaryhtml]; + } +} diff --git a/classes/local/feedback/feedback.php b/classes/local/feedback/feedback.php new file mode 100644 index 000000000..9382753a4 --- /dev/null +++ b/classes/local/feedback/feedback.php @@ -0,0 +1,599 @@ +. + +/** + * Feedback domain object. + * + * @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 html_table; +use html_writer; +use mod_questionnaire\local\db\feedback_record; +use mod_questionnaire\local\db\feedback_section_record; +use mod_questionnaire\local\db\response_record; +use mod_questionnaire\questionnaire; +use stdClass; + +/** + * Feedback domain object. + * + * Wraps the feedback-related state that lives on the `questionnaire_survey` + * table (feedbacknotes, feedbacksections, feedbackscores, charttype) plus + * the `questionnaire_fb_sections` collection. The `update_settings` write + * path owns its own allowlist; the `response_analysis` extraction is + * deferred to a later phase. + * + * @see project-feedback-domain-class-plan + */ +class feedback { + /** @var string[] Survey persistent fields owned by the feedback concern. */ + public const UPDATABLE_FIELDS = [ + 'feedbacknotes', 'feedbacksections', 'feedbackscores', 'charttype', + ]; + + /** + * Constructor. + * + * @param questionnaire $questionnaire The owning questionnaire. + */ + public function __construct( + /** @var questionnaire The owning questionnaire. */ + private readonly questionnaire $questionnaire, + ) { + } + + /** + * True if feedback is enabled (any sections configured). + * + * @return bool + */ + public function enabled(): bool { + return $this->mode() > 0; + } + + /** + * Number of feedback sections — 0 off, 1 single global, N sectioned. + * + * Stored under feedbacksections on questionnaire_survey. + * + * @return int + */ + public function mode(): int { + return (int) $this->questionnaire->survey()->raw_field('feedbacksections'); + } + + /** + * True if the per-section feedback score table should be shown. + * + * Stored under feedbackscores on questionnaire_survey. + * + * @return bool + */ + public function show_scores(): bool { + return (bool) $this->questionnaire->survey()->raw_field('feedbackscores'); + } + + /** + * Raw HTML for the feedback notes editor. + * + * @return string + */ + public function notes(): string { + return $this->questionnaire->survey()->raw_field('feedbacknotes') ?? ''; + } + + /** + * Feedback notes with pluginfile URLs rewritten for display. + * + * @return string Empty string when notes() is empty; the rewritten HTML otherwise. + */ + public function rendered_notes(): string { + $notes = $this->notes(); + if ($notes === '') { + return ''; + } + return file_rewrite_pluginfile_urls( + $notes, + 'pluginfile.php', + $this->questionnaire->context()->id, + 'mod_questionnaire', + 'feedbacknotes', + $this->questionnaire->surveyid() + ); + } + + /** + * Chart type for per-section feedback rendering — '' when off. + * + * Stored under charttype on questionnaire_survey. Empty string when + * the user disabled the chart or when the site usergraph setting is + * off. + * + * @return string + */ + public function chart_type(): string { + return $this->questionnaire->survey()->raw_field('charttype') ?? ''; + } + + /** + * True if any question on the survey is a valid feedback question + * (rate, yesno, slider — anything where valid_feedback() returns true). + * + * @return bool + */ + public function has_any_feedback_questions(): bool { + foreach ($this->questionnaire->questions() as $question) { + if ($question->valid_feedback()) { + return true; + } + } + return false; + } + + /** + * Update feedback-domain fields on the survey row. + * + * Only keys in self::UPDATABLE_FIELDS are accepted; passing anything else + * throws a coding_exception so form metadata cannot be smuggled onto the + * persistent. The actual write goes through {@see survey::update_settings()} + * with the feedback allowlist as the package-private override. + * + * @param array $fields Map of feedback field name => value. + * @return int|false Survey id on success, false on validation failure. + */ + public function update_settings(array $fields): int|false { + foreach (array_keys($fields) as $key) { + if (!in_array($key, self::UPDATABLE_FIELDS, true)) { + throw new \coding_exception("feedback::update_settings: field '{$key}' is not updatable"); + } + } + return $this->questionnaire->survey()->update_settings($fields, self::UPDATABLE_FIELDS); + } + + /** + * Compute the per-section / per-question feedback scoreboard for a response set. + * + * Returns null when feedback is not configured (no sections, or no + * questions contribute to the max score). Otherwise returns a value + * object carrying the rendered message HTML chunks, the optional score + * table HTML, and the optional chart HTML. Callers are responsible for + * applying the table/chart strings to the page; messages are returned + * raw so callers can compose them however they need. + * + * Logic is a verbatim move of the former reporter::response_analysis; + * the math has not been rewritten. The boxed-message HTML is built from + * $renderer->box_start() / box_end() so the wrapper styling stays + * identical to the legacy output. + * + * @param int $rid Response id of the individual response being viewed (0 for all). + * @param array $resps All response objects keyed by response id. + * @param bool $compare True when comparing individual response against group. + * @param bool $isgroupmember True when the viewing user is a group member. + * @param bool $allresponses True when all responses are included (not just one). + * @param int $currentgroupid Active group filter id (0 = all). + * @param string $sortaction Report action triggering sort ('vallasort' / 'vallarsort' / other). + * @param \plugin_renderer_base $renderer Plugin renderer for box wrappers. + * @param array|null $filteredsections Restrict output to these section numbers (null = all). + * @return scoreboard|null Scoreboard value object, or null when no feedback is configured. + */ + public function build_scoreboard( + int $rid, + array $resps, + bool $compare, + bool $isgroupmember, + bool $allresponses, + int $currentgroupid, + string $sortaction, + \plugin_renderer_base $renderer, + ?array $filteredsections = null, + ): ?scoreboard { + global $DB, $CFG, $PAGE; + require_once($CFG->libdir . '/tablelib.php'); + $fbsections = feedback_section_record::get_numbered_section_records_for_survey( + $this->questionnaire->surveyid() + ); + if (empty($fbsections)) { + return null; + } + $charttitle = ($PAGE->pagetype == 'mod-questionnaire-myreport') + ? get_string('yourresponse', 'questionnaire') + : get_string('thisresponse', 'questionnaire'); + + $resp = response_record::get_record(['id' => $rid]); + $ruser = ''; + if ($resp !== false) { + $userid = (int) $resp->get('userid'); + // The user table is Moodle core; raw $DB access is the documented convention there. + $user = $DB->get_record('user', ['id' => $userid]); + if (!empty($user)) { + if ($this->questionnaire->respondenttype() == 'anonymous') { + $ruser = '- ' . get_string('anonymous', 'questionnaire') . ' -'; + } else { + $ruser = fullname($user); + } + } + } + + $groupmode = groups_get_activity_groupmode($this->questionnaire->coursemodule(), $this->questionnaire->course()); + $groupname = get_string('allparticipants'); + if ($groupmode > 0) { + if ($currentgroupid > 0) { + $groupname = groups_get_group_name($currentgroupid); + } else { + $groupname = get_string('allparticipants'); + } + } + + $table = null; + if ($this->show_scores()) { + $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); + + $rids = []; + foreach ($resps as $key => $resp) { + $rids[] = $key; + } + $nbparticipants = count($rids); + $responsescores = []; + + $qmax = []; + $maxtotalscore = 0; + $questions = $this->questionnaire->questions(); + foreach ($questions as $question) { + $qid = $question->id(); + if ($question->valid_feedback()) { + $qmax[$qid] = $question->get_feedback_maxscore(); + $maxtotalscore += $qmax[$qid]; + $responsescores[$qid] = $question->get_feedback_scores($rids); + } + } + if ($maxtotalscore === 0) { + return null; + } + $feedbackmessages = []; + + $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 ($rrid == $rid || $allresponses) { + if (!isset($qscore[$qid])) { + $qscore[$qid] = 0; + } + $qscore[$qid] = $response->score; + } + if (!isset($allqscore[$qid])) { + $allqscore[$qid] = 0; + } + 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); + + if ($this->mode() == 1) { + $sectionid = $fbsectionsnb[0]; + $sectionlabel = $fbsections[$sectionid]->sectionlabel; + $sectionheading = $fbsections[$sectionid]->sectionheading; + $labels = []; + foreach (feedback_record::get_records_for_section($sectionid) as $feedback) { + if ($feedback->feedbacklabel != '') { + $labels[] = $feedback->feedbacklabel; + } + } + $feedback = feedback_record::find_for_score($sectionid, (float) $scorepercent); + + $sectionheading = str_replace('%', '', $sectionheading); + $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->questionnaire->context()->id, + 'mod_questionnaire', + 'sectionheading', + $sectionid + ); + $feedbackmessages[] = $renderer->box_start(); + $feedbackmessages[] = format_text($sectionheading, FORMAT_HTML, ['noclean' => true]); + $feedbackmessages[] = $renderer->box_end(); + + if (!empty($feedback->feedbacktext)) { + $formatoptions = new stdClass(); + $formatoptions->noclean = true; + $feedbacktext = file_rewrite_pluginfile_urls( + $feedback->feedbacktext, + 'pluginfile.php', + $this->questionnaire->context()->id, + 'mod_questionnaire', + 'feedback', + $feedback->id + ); + $feedbacktext = format_text($feedbacktext, $feedback->feedbacktextformat, $formatoptions); + $feedbackmessages[] = $renderer->box_start(); + $feedbackmessages[] = $feedbacktext; + $feedbackmessages[] = $renderer->box_end(); + } + $score = [$scorepercent, 100 - $scorepercent]; + $allscore = null; + if ($compare || $allresponses) { + $allscore = [$allscorepercent, 100 - $allscorepercent]; + } + $usergraph = get_config('questionnaire', 'usergraph'); + $charthtml = null; + if ($usergraph && $this->chart_type()) { + $charthtml = chart_renderer::render( + $PAGE, + $feedbacktype = 'global', + $labels, + $groupname, + $allresponses, + $this->chart_type(), + $score, + $allscore, + $sectionlabel, + $charttitle + ); + } + $lb = explode("|", $sectionlabel); + $oppositescore = ''; + $oppositeallscore = ''; + if (count($lb) > 1) { + $sectionlabel = $lb[0] . ' | ' . $lb[1]; + $oppositescore = ' | ' . $score[1] . '%'; + $oppositeallscore = ' | ' . $allscore[1] . '%'; + } + $tablehtml = null; + if ($this->show_scores()) { + $table = $table ?? new html_table(); + if ($compare) { + $table->data[] = [$sectionlabel, $score[0] . '%' . $oppositescore, $allscore[0] . '%' . $oppositeallscore]; + } else { + $table->data[] = [$sectionlabel, $allscore[0] . '%' . $oppositeallscore]; + } + $tablehtml = html_writer::table($table); + } + + return new scoreboard($feedbackmessages, $tablehtml, $charthtml); + } + + // Now process scores for more than one section. + + $score = []; + $allscore = []; + $maxscore = []; + $scorepercent = []; + $allscorepercent = []; + $oppositescorepercent = []; + $alloppositescorepercent = []; + $chartlabels = []; + $nanscores = []; + + for ($i = 1; $i <= $numsections; $i++) { + $score[$i] = 0; + $allscore[$i] = 0; + $maxscore[$i] = 0; + $scorepercent[$i] = 0; + } + + $sectionlabel = ''; + for ($section = 1; $section <= $numsections; $section++) { + 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) { + 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])) { + continue; + } + $sectionheading = str_replace('%', '', $sectionheading); + $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->questionnaire->context()->id, + 'mod_questionnaire', + 'sectionheading', + $imageid + ); + $sectionheading = format_text($sectionheading, 1, $formatoptions); + $feedbackmessages[] = $renderer->box_start('reportQuestionTitle'); + $feedbackmessages[] = format_text($sectionheading, FORMAT_HTML, $formatoptions); + $feedback = feedback_record::find_for_score( + $feedbacksectionid, + (float) $scorepercent[$section], + 'id,feedbacktext,feedbacktextformat' + ); + $feedbackmessages[] = $renderer->box_end(); + if (!empty($feedback->feedbacktext)) { + $formatoptions = new stdClass(); + $formatoptions->noclean = true; + $feedbacktext = file_rewrite_pluginfile_urls( + $feedback->feedbacktext, + 'pluginfile.php', + $this->questionnaire->context()->id, + 'mod_questionnaire', + 'feedback', + $feedback->id + ); + $feedbacktext = format_text($feedbacktext, $feedback->feedbacktextformat, $formatoptions); + $feedbackmessages[] = $renderer->box_start('feedbacktext'); + $feedbackmessages[] = $feedbacktext; + $feedbackmessages[] = $renderer->box_end(); + } + } + } + + switch ($sortaction) { + case 'vallasort': + asort($allscore); + break; + case 'vallarsort': + arsort($allscore); + break; + default: + } + + $tablehtml = null; + if ($this->show_scores()) { + 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 ($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]; + } + } + } + $tablehtml = html_writer::table($table); + } + $usergraph = get_config('questionnaire', 'usergraph'); + + foreach ($nanscores as $val) { + unset($chartlabels[$val]); + unset($scorepercent[$val]); + unset($allscorepercent[$val]); + } + + $charthtml = null; + if ($usergraph && $this->chart_type()) { + $charthtml = chart_renderer::render( + $PAGE, + 'sections', + array_values($chartlabels), + $groupname, + $allresponses, + $this->chart_type(), + array_values($scorepercent), + array_values($allscorepercent), + $sectionlabel, + $charttitle + ); + } + + return new scoreboard($feedbackmessages, $tablehtml, $charthtml); + } + + /** + * Ensure a first feedback section exists for the survey. + * + * Used by the "Save settings and edit Feedback Sections" path so the + * user always lands on a real section editor. Returns the id of the + * section to redirect to (existing first section, or newly created one). + * + * @return int Section id (0 if no sections exist and no feedback is configured). + */ + public function ensure_first_section(): int { + $surveyid = $this->questionnaire->surveyid(); + $firstsection = feedback_section_record::min_section_for_survey($surveyid); + + $mode = $this->mode(); + if ($mode > 0 && $firstsection === 0) { + $label = ($mode === 1) + ? get_string('feedbackglobal', 'questionnaire') + : get_string('feedbackdefaultlabel', 'questionnaire'); + section::new_section($surveyid, $label); + } + return $firstsection; + } +} diff --git a/classes/feedback_form.php b/classes/local/feedback/feedback_form.php similarity index 93% rename from classes/feedback_form.php rename to classes/local/feedback/feedback_form.php index 3733fa81b..045bd011a 100644 --- a/classes/feedback_form.php +++ b/classes/local/feedback/feedback_form.php @@ -14,7 +14,7 @@ // You should have received a copy of the GNU General Public License // along with Moodle. If not, see . -namespace mod_questionnaire; +namespace mod_questionnaire\local\feedback; defined('MOODLE_INTERNAL') || die(); @@ -46,7 +46,7 @@ public function definition() { $feedbackoptions[2] = get_string('feedbacksections', 'questionnaire'); $mform->addElement('select', 'feedbacksections', get_string('feedbackoptions', 'questionnaire'), $feedbackoptions); - $mform->setDefault('feedbacksections', $questionnaire->survey->feedbacksections); + $mform->setDefault('feedbacksections', $questionnaire->feedback()->mode()); $mform->addHelpButton('feedbacksections', 'feedbackoptions', 'questionnaire'); $options = ['0' => get_string('no'), '1' => get_string('yes')]; @@ -66,8 +66,8 @@ public function definition() { get_string('feedbackglobal', 'questionnaire') . ')', $charttypes ); - if ($questionnaire->survey->feedbacksections == 1) { - $mform->setDefault('chart_type_global', $questionnaire->survey->chart_type); + if ($questionnaire->feedback()->mode() == 1) { + $mform->setDefault('chart_type_global', $questionnaire->feedback()->chart_type()); } $mform->disabledIf('chart_type_global', 'feedbacksections', 'eq', 0); $mform->disabledIf('chart_type_global', 'feedbacksections', 'neq', 1); @@ -83,8 +83,8 @@ public function definition() { get_string('feedbackbysection', 'questionnaire') . ')', $charttypes ); - if ($questionnaire->survey->feedbacksections > 1) { - $mform->setDefault('chart_type_two_sections', $questionnaire->survey->chart_type); + if ($questionnaire->feedback()->mode() > 1) { + $mform->setDefault('chart_type_two_sections', $questionnaire->feedback()->chart_type()); } $mform->disabledIf('chart_type_two_sections', 'feedbacksections', 'neq', 2); @@ -100,8 +100,8 @@ public function definition() { get_string('feedbackbysection', 'questionnaire') . ')', $charttypes ); - if ($questionnaire->survey->feedbacksections > 1) { - $mform->setDefault('chart_type_sections', $questionnaire->survey->chart_type); + if ($questionnaire->feedback()->mode() > 1) { + $mform->setDefault('chart_type_sections', $questionnaire->feedback()->chart_type()); } $mform->disabledIf('chart_type_sections', 'feedbacksections', 'eq', 0); $mform->disabledIf('chart_type_sections', 'feedbacksections', 'eq', 1); @@ -119,7 +119,7 @@ public function definition() { $editoroptions = ['maxfiles' => EDITOR_UNLIMITED_FILES, 'trusttext' => true]; $mform->addElement('editor', 'feedbacknotes', get_string('feedbacknotes', 'questionnaire'), null, $editoroptions); $mform->setType('feedbacknotes', PARAM_RAW); - $mform->setDefault('feedbacknotes', $questionnaire->survey->feedbacknotes); + $mform->setDefault('feedbacknotes', $questionnaire->feedback()->notes()); $mform->addHelpButton('feedbacknotes', 'feedbacknotes', 'questionnaire'); $mform->addElement('hidden', 'id', 0); diff --git a/classes/feedback_section_form.php b/classes/local/feedback/feedback_section_form.php similarity index 92% rename from classes/feedback_section_form.php rename to classes/local/feedback/feedback_section_form.php index 88cc173b5..ae17ff8a2 100644 --- a/classes/feedback_section_form.php +++ b/classes/local/feedback/feedback_section_form.php @@ -14,14 +14,13 @@ // You should have received a copy of the GNU General Public License // along with Moodle. If not, see . -namespace mod_questionnaire; +namespace mod_questionnaire\local\feedback; defined('MOODLE_INTERNAL') || die(); require_once($CFG->libdir . '/formslib.php'); require_once($CFG->dirroot . '/mod/questionnaire/lib.php'); -#[\AllowDynamicProperties] /** * Print the form to add or edit a questionnaire-instance * @@ -38,6 +37,21 @@ class feedback_section_form extends \moodleform { */ public $context; + /** @var \plugin_renderer_base The plugin renderer used by definition(). */ + protected \plugin_renderer_base $renderer; + + /** + * Constructor — accepts the standard moodleform args plus the plugin renderer. + * + * @param mixed $action + * @param mixed $customdata + * @param \plugin_renderer_base $renderer The plugin renderer. + */ + public function __construct($action, $customdata, \plugin_renderer_base $renderer) { + $this->renderer = $renderer; + parent::__construct($action, $customdata); + } + /** * Form definition. */ @@ -47,9 +61,9 @@ public function definition() { $feedbacksection = $this->_customdata->feedbacksection; $validquestions = $this->_customdata->validquestions; $survey = $this->_customdata->survey; - $feedbacksections = $questionnaire->survey->feedbacksections; + $feedbacksections = $questionnaire->feedback()->mode(); $this->feedbacks = $feedbacksection->sectionfeedback; - $this->context = $questionnaire->context; + $this->context = $questionnaire->context(); $mform =& $this->_form; if ($survey->feedbacksections > 1) { @@ -106,10 +120,10 @@ public function definition() { $editoroptions ); $mform->setType('sectionheading', PARAM_RAW); - $mform->setDefault('feedbacknotes', $questionnaire->survey->feedbacknotes); + $mform->setDefault('feedbacknotes', $questionnaire->feedback()->notes()); $mform->addHelpButton('sectionheading', 'feedbackheading', 'questionnaire'); - if ($questionnaire->survey->feedbacksections > 0) { + if ($questionnaire->feedback()->mode() > 0) { // Sections. if ($survey->feedbacksections > 1) { $mform->addElement( @@ -119,7 +133,7 @@ public function definition() { ); $qvalid = $validquestions; if (!empty($feedbacksection->scorecalculation)) { - $rsrc = $questionnaire->renderer->image_url('t/delete'); + $rsrc = $this->renderer->image_url('t/delete'); $strremove = get_string('remove', 'questionnaire'); $rextra = ['alt' => $strremove, 'title' => $strremove]; $counter = 1; @@ -139,7 +153,7 @@ public function definition() { unset($rextra['style']); $questionactions[] = $mform->createElement('image', 'confirmremovequestion[' . $qid . ']', $rsrc, $rextra); - $mform->addGroup($questionactions, '', $questionnaire->questions[$qid]->name); + $mform->addGroup($questionactions, '', $questionnaire->questions()[$qid]->name()); $counter++; } } @@ -173,7 +187,7 @@ public function definition() { 'feedbacktext', get_string('feedback', 'questionnaire'), null, - ['maxfiles' => EDITOR_UNLIMITED_FILES, 'noclean' => true, 'context' => $questionnaire->context] + ['maxfiles' => EDITOR_UNLIMITED_FILES, 'noclean' => true, 'context' => $questionnaire->context()] ); $repeatarray[] = $mform->createElement( 'text', @@ -205,7 +219,7 @@ public function definition() { "feedbacktext[$nextel]", get_string('feedback', 'questionnaire'), null, - ['maxfiles' => EDITOR_UNLIMITED_FILES, 'noclean' => true, 'context' => $questionnaire->context] + ['maxfiles' => EDITOR_UNLIMITED_FILES, 'noclean' => true, 'context' => $questionnaire->context()] ), 'boundary_add_fields' ); diff --git a/classes/local/feedback/feedback_settings_controller.php b/classes/local/feedback/feedback_settings_controller.php new file mode 100644 index 000000000..30e9b19ff --- /dev/null +++ b/classes/local/feedback/feedback_settings_controller.php @@ -0,0 +1,127 @@ +. + +namespace mod_questionnaire\local\feedback; + +use mod_questionnaire\questionnaire; + +/** + * Controller for the feedback.php save / redirect flow. + * + * Owns the conversion from feedback form data into an explicit allowlist of + * feedback settings, the saving of the feedbacknotes editor draft area, and + * the redirect to the section editor. Domain validation of the field set + * lives on {@see feedback::update_settings()}; section bootstrap lives on + * {@see feedback::ensure_first_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 + */ +class feedback_settings_controller { + /** + * Constructor. + * + * @param questionnaire $questionnaire The active questionnaire. + */ + public function __construct( + /** @var questionnaire The active questionnaire. */ + private readonly questionnaire $questionnaire, + ) { + } + + /** + * Persist feedback settings from parsed form data. + * + * Writes the feedbacknotes draft area and applies the field set via + * {@see feedback::update_settings()}. Throws if the underlying update + * rejects the changes. + * + * @param \stdClass $formdata Validated form data returned by feedback_form::get_data(). + * @return int Survey id on success. + */ + public function save(\stdClass $formdata): int { + $fields = [ + 'feedbackscores' => isset($formdata->feedbackscores) ? (int) $formdata->feedbackscores : 0, + 'feedbacknotes' => $this->save_feedbacknotes($formdata), + 'feedbacksections' => isset($formdata->feedbacksections) ? (int) $formdata->feedbacksections : 0, + ]; + + if ($fields['feedbacksections'] > 0 && get_config('questionnaire', 'usergraph')) { + $charttype = $this->extract_charttype($formdata, $fields['feedbacksections']); + if ($charttype !== null) { + $fields['charttype'] = $charttype; + } + } + + $result = $this->questionnaire->feedback()->update_settings($fields); + if ($result === false) { + throw new \moodle_exception('couldnotcreatenewsurvey', 'mod_questionnaire'); + } + return $result; + } + + /** + * Forward to {@see feedback::ensure_first_section()} for use by the + * "Save settings and edit Feedback Sections" path in feedback.php. + * + * @return int Section id (0 if no sections exist and no feedback is configured). + */ + public function ensure_first_section(): int { + return $this->questionnaire->feedback()->ensure_first_section(); + } + + /** + * Save the feedbacknotes editor draft area and return the processed HTML body. + * + * Empty / missing editor data resolves to an empty string so the survey field + * is positively cleared instead of being left stale. + * + * @param \stdClass $formdata + * @return string + */ + private function save_feedbacknotes(\stdClass $formdata): string { + if (!isset($formdata->feedbacknotes) || !is_array($formdata->feedbacknotes)) { + return ''; + } + return file_save_draft_area_files( + (int) $formdata->feedbacknotes['itemid'], + $this->questionnaire->context()->id, + 'mod_questionnaire', + 'feedbacknotes', + $this->questionnaire->surveyid(), + ['subdirs' => true], + $formdata->feedbacknotes['text'] + ); + } + + /** + * Resolve which charttype the form picked given the number of sections. + * + * @param \stdClass $formdata + * @param int $sections + * @return string|null + */ + private function extract_charttype(\stdClass $formdata, int $sections): ?string { + return match (true) { + $sections === 1 => $formdata->chart_type_global ?? null, + $sections === 2 => $formdata->chart_type_two_sections ?? null, + $sections > 2 => $formdata->chart_type_sections ?? null, + default => null, + }; + } +} diff --git a/classes/local/feedback/scoreboard.php b/classes/local/feedback/scoreboard.php new file mode 100644 index 000000000..ff461daf2 --- /dev/null +++ b/classes/local/feedback/scoreboard.php @@ -0,0 +1,53 @@ +. + +/** + * Feedback scoreboard value object. + * + * @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; + +/** + * The data side of a feedback render: messages, score table, chart. + * + * Built by {@see feedback::build_scoreboard()}; consumed by + * {@see \mod_questionnaire\local\report\reporter::response_analysis()} which applies the + * matching `add_to_page` side effects. Each field is independent — any subset + * may be null/empty depending on the questionnaire's feedback configuration. + */ +class scoreboard { + /** + * Constructor. + * + * @param string[] $messages HTML chunks for the feedback messages output area. + * @param string|null $table Pre-rendered feedbackscores table HTML, null when not shown. + * @param string|null $chart Pre-rendered feedbackcharts HTML, null when not shown. + */ + public function __construct( + /** @var string[] HTML chunks for the feedback messages output area. */ + public readonly array $messages, + /** @var string|null Pre-rendered feedbackscores table HTML, null when not shown. */ + public readonly ?string $table, + /** @var string|null Pre-rendered feedbackcharts HTML, null when not shown. */ + public readonly ?string $chart, + ) { + } +} diff --git a/classes/feedback/section.php b/classes/local/feedback/section.php similarity index 76% rename from classes/feedback/section.php rename to classes/local/feedback/section.php index df05c2824..52cb354c8 100644 --- a/classes/feedback/section.php +++ b/classes/local/feedback/section.php @@ -14,14 +14,13 @@ // You should have received a copy of the GNU General Public License // along with Moodle. If not, see . -namespace mod_questionnaire\feedback; - -defined('MOODLE_INTERNAL') || die(); +namespace mod_questionnaire\local\feedback; +use mod_questionnaire\local\db\feedback_record; +use mod_questionnaire\local\db\feedback_section_record; use invalid_parameter_exception; use coding_exception; -#[\AllowDynamicProperties] /** * Class for describing a feedback section. * @@ -49,6 +48,10 @@ class section { public $sectionfeedback = []; /** @var array */ public $questions = []; + /** @var int Survey id set externally (e.g. in fbsections.php form data). */ + public $sid = 0; + /** @var int Section id set externally (e.g. in fbsections.php form data). */ + public $sectionid = 0; /** The table name. */ const TABLE = 'questionnaire_fb_sections'; @@ -89,19 +92,26 @@ public function __construct($questions, $params = []) { * @return section */ public static function new_section($surveyid, $sectionlabel = '') { - global $DB; - $newsection = new self([], []); if (empty($sectionlabel)) { $sectionlabel = get_string('feedbackdefaultlabel', 'questionnaire'); } - $maxsection = $DB->get_field(self::TABLE, 'MAX(section)', ['surveyid' => $surveyid]); $newsection->surveyid = $surveyid; - $newsection->section = $maxsection + 1; + $newsection->section = feedback_section_record::max_section_for_survey($surveyid) + 1; $newsection->sectionlabel = $sectionlabel; $newsection->scorecalculation = $newsection->encode_scorecalculation([]); - $newsecid = $DB->insert_record(self::TABLE, $newsection); - $newsection->id = $newsecid; + + $record = new feedback_section_record(0, (object)[ + 'surveyid' => $newsection->surveyid, + 'section' => $newsection->section, + 'sectionlabel' => $newsection->sectionlabel, + 'sectionheading' => $newsection->sectionheading, + 'sectionheadingformat' => $newsection->sectionheadingformat, + 'scorecalculation' => $newsection->scorecalculation, + ]); + $record->create(); + + $newsection->id = $record->get('id'); $newsection->scorecalculation = []; return $newsection; } @@ -118,42 +128,33 @@ public static function new_section($surveyid, $sectionlabel = '') { * @throws invalid_parameter_exception */ public function load_section($params) { - global $DB; - if (!is_array($params)) { throw new coding_exception('Invalid data provided.'); } else if (isset($params['id'])) { - $where = 'WHERE fs.id = :id '; + $sectionrec = feedback_section_record::get_record(['id' => $params['id']]); } else if (isset($params['surveyid'])) { - $where = 'WHERE fs.surveyid = :surveyid AND fs.section = :sectionnum '; - if (!isset($params['sectionnum'])) { - $params['sectionnum'] = 1; - } + $sectionrec = feedback_section_record::get_record([ + 'surveyid' => $params['surveyid'], + 'section' => $params['sectionnum'] ?? 1, + ]); } else { throw new coding_exception('No valid data parameters provided.'); } - $select = 'SELECT f.id as fbid, fs.*, f.feedbacklabel, f.feedbacktext, f.feedbacktextformat, f.minscore, f.maxscore '; - $from = 'FROM {' . self::TABLE . '} fs LEFT JOIN {' . sectionfeedback::TABLE . '} f ON fs.id = f.sectionid '; - $order = 'ORDER BY minscore DESC'; - - if (!($feedbackrecs = $DB->get_records_sql($select . $from . $where . $order, $params))) { + if (!$sectionrec) { throw new invalid_parameter_exception('No feedback sections exists for that data.'); } - foreach ($feedbackrecs as $fbid => $feedbackrec) { - if (empty($this->id)) { - $this->id = $feedbackrec->id; - $this->surveyid = $feedbackrec->surveyid; - $this->section = $feedbackrec->section; - $this->scorecalculation = $this->get_valid_scorecalculation($feedbackrec->scorecalculation); - $this->sectionlabel = $feedbackrec->sectionlabel; - $this->sectionheading = $feedbackrec->sectionheading; - $this->sectionheadingformat = $feedbackrec->sectionheadingformat; - } - if (!empty($fbid)) { - $feedbackrec->id = $fbid; - $this->sectionfeedback[$fbid] = new sectionfeedback(0, $feedbackrec); - } + + $this->id = $sectionrec->get('id'); + $this->surveyid = $sectionrec->get('surveyid'); + $this->section = $sectionrec->get('section'); + $this->scorecalculation = $this->get_valid_scorecalculation($sectionrec->get('scorecalculation')); + $this->sectionlabel = $sectionrec->get('sectionlabel'); + $this->sectionheading = $sectionrec->get('sectionheading'); + $this->sectionheadingformat = $sectionrec->get('sectionheadingformat'); + + foreach (feedback_record::get_for_section($this->id, 'minscore', 'DESC') as $feedbackrec) { + $this->sectionfeedback[$feedbackrec->get('id')] = new sectionfeedback(0, $feedbackrec->to_record()); } } @@ -181,15 +182,15 @@ public function load_sectionfeedback($feedbackrec) { * @throws coding_exception */ public function set_new_scorecalculation($scorecalculation = null) { - global $DB; - if ($scorecalculation == null) { $scorecalculation = $this->scorecalculation; } if (is_array($scorecalculation)) { $newscore = $this->encode_scorecalculation($scorecalculation); - $DB->set_field(self::TABLE, 'scorecalculation', $newscore, ['id' => $this->id]); + $record = new feedback_section_record($this->id); + $record->set('scorecalculation', $newscore); + $record->update(); $this->scorecalculation = $scorecalculation; } else { throw new coding_exception('Invalid scorecalculation format.'); @@ -215,20 +216,19 @@ public function remove_question($qid) { * This will also adjust the section numbers so that they are sequential and begin at 1. */ public function delete() { - global $DB; - $this->delete_sectionfeedback(); - $DB->delete_records(self::TABLE, ['id' => $this->id]); + + $record = new feedback_section_record($this->id); + $record->delete(); // Resequence the section numbers as necessary. - if ($allsections = $DB->get_records(self::TABLE, ['surveyid' => $this->surveyid], 'section ASC')) { - $count = 1; - foreach ($allsections as $id => $section) { - if ($section->section != $count) { - $DB->set_field(self::TABLE, 'section', $count, ['id' => $id]); - } - $count++; + $count = 1; + foreach (feedback_section_record::get_for_survey($this->surveyid, 'section') as $section) { + if ($section->get('section') != $count) { + $section->set('section', $count); + $section->update(); } + $count++; } } @@ -238,10 +238,8 @@ public function delete() { * @throws \dml_exception */ public function delete_sectionfeedback() { - global $DB; - // It's quicker to delete all of the records at once then to go through the array and delete each object. - $DB->delete_records(sectionfeedback::TABLE, ['sectionid' => $this->id]); + feedback_record::delete_for_section($this->id); $this->sectionfeedback = []; } @@ -252,10 +250,18 @@ public function delete_sectionfeedback() { * @throws coding_exception */ public function update() { - global $DB; - $this->scorecalculation = $this->encode_scorecalculation($this->scorecalculation); - $DB->update_record(self::TABLE, $this); + + $record = new feedback_section_record($this->id, (object)[ + 'surveyid' => $this->surveyid, + 'section' => $this->section, + 'sectionlabel' => $this->sectionlabel, + 'sectionheading' => $this->sectionheading, + 'sectionheadingformat' => $this->sectionheadingformat, + 'scorecalculation' => $this->scorecalculation, + ]); + $record->update(); + $this->scorecalculation = $this->get_valid_scorecalculation($this->scorecalculation); foreach ($this->sectionfeedback as $sectionfeedback) { diff --git a/classes/feedback/sectionfeedback.php b/classes/local/feedback/sectionfeedback.php similarity index 74% rename from classes/feedback/sectionfeedback.php rename to classes/local/feedback/sectionfeedback.php index 69e562913..6288d7485 100644 --- a/classes/feedback/sectionfeedback.php +++ b/classes/local/feedback/sectionfeedback.php @@ -14,8 +14,9 @@ // You should have received a copy of the GNU General Public License // along with Moodle. If not, see . -namespace mod_questionnaire\feedback; +namespace mod_questionnaire\local\feedback; +use mod_questionnaire\local\db\feedback_record; use invalid_parameter_exception; use coding_exception; @@ -54,10 +55,11 @@ class sectionfeedback { public function __construct($id = 0, $record = null) { // Return a new section based on the data id. if ($id != 0) { - $record = $this->get_sectionfeedback($id); - if (!$record) { + $persistent = feedback_record::get_record(['id' => $id]); + if (!$persistent) { throw new invalid_parameter_exception('No section feedback exists with that ID.'); } + $record = $persistent->to_record(); } if (($id != 0) || is_object($record)) { $this->loadproperties($record); @@ -70,7 +72,6 @@ public function __construct($id = 0, $record = null) { * @return sectionfeedback */ public static function new_sectionfeedback($data) { - global $DB; $newsf = new self(); $newsf->sectionid = $data->sectionid; $newsf->feedbacklabel = $data->feedbacklabel; @@ -78,8 +79,18 @@ public static function new_sectionfeedback($data) { $newsf->feedbacktextformat = $data->feedbacktextformat; $newsf->minscore = $data->minscore; $newsf->maxscore = $data->maxscore; - $newsfid = $DB->insert_record(self::TABLE, $newsf); - $newsf->id = $newsfid; + + $record = new feedback_record(0, (object)[ + 'sectionid' => $newsf->sectionid, + 'feedbacklabel' => $newsf->feedbacklabel, + 'feedbacktext' => $newsf->feedbacktext, + 'feedbacktextformat' => $newsf->feedbacktextformat, + 'minscore' => $newsf->minscore, + 'maxscore' => $newsf->maxscore, + ]); + $record->create(); + + $newsf->id = $record->get('id'); return $newsf; } @@ -90,20 +101,15 @@ public static function new_sectionfeedback($data) { * @throws coding_exception */ public function update() { - global $DB; - - $DB->update_record(self::TABLE, $this); - } - - /** - * Return the record specified by the id. - * @param int $id - * @return mixed - */ - protected function get_sectionfeedback($id) { - global $DB; - - return $DB->get_record(self::TABLE, ['id' => $id]); + $record = new feedback_record($this->id, (object)[ + 'sectionid' => $this->sectionid, + 'feedbacklabel' => $this->feedbacklabel, + 'feedbacktext' => $this->feedbacktext, + 'feedbacktextformat' => $this->feedbacktextformat, + 'minscore' => $this->minscore, + 'maxscore' => $this->maxscore, + ]); + $record->update(); } /** @@ -118,17 +124,4 @@ protected function loadproperties($record) { } } } - - /** - * Get the data for this section's feedback from the database. - * - * @param int $id - * @return mixed - * @throws \dml_exception - */ - protected function get_section($id) { - global $DB; - - return $DB->get_record(self::TABLE, ['id' => $id]); - } } diff --git a/classes/question/check.php b/classes/local/question/check.php similarity index 84% rename from classes/question/check.php rename to classes/local/question/check.php index 124bf1b0d..510de2c57 100644 --- a/classes/question/check.php +++ b/classes/local/question/check.php @@ -14,7 +14,7 @@ // You should have received a copy of the GNU General Public License // along with Moodle. If not, see . -namespace mod_questionnaire\question; +namespace mod_questionnaire\local\question; /** * This file contains the parent class for check question types. @@ -30,7 +30,7 @@ class check extends question { * @return string */ protected function responseclass() { - return '\\mod_questionnaire\\responsetype\\multiple'; + return '\\mod_questionnaire\\local\\response\\multiple'; } /** @@ -74,7 +74,7 @@ public function allows_dependents() { /** * Return the context tags for the check question template. - * @param \mod_questionnaire\responsetype\response\response $response + * @param \mod_questionnaire\local\response\response $response * @param array $dependants Array of all questions/choices depending on this question. * @param boolean $blankquestionnaire * @return \stdClass The check question context tags. @@ -85,18 +85,18 @@ protected function question_survey_display($response, $dependants, $blankquestio $otherempty = false; if (!empty($response)) { // Verify that number of checked boxes (nbboxes) is within set limits (length = min; precision = max). - if (!empty($response->answers[$this->id])) { + if (!empty($response->answers[$this->id()])) { $otherempty = false; - $nbboxes = count($response->answers[$this->id]); - foreach ($response->answers[$this->id] as $answer) { + $nbboxes = count($response->answers[$this->id()]); + foreach ($response->answers[$this->id()] as $answer) { $choice = $this->choices[$answer->choiceid]; if ($choice->is_other_choice()) { $otherempty = empty($answer->value); } } $nbchoices = count($this->choices); - $min = $this->length; - $max = $this->precise; + $min = $this->length(); + $max = $this->precise(); if ($max == 0) { $max = $nbchoices; } @@ -129,12 +129,12 @@ protected function question_survey_display($response, $dependants, $blankquestio $choicetags->qelements = []; foreach ($this->choices as $id => $choice) { $checkbox = new \stdClass(); - $contents = questionnaire_choice_values($choice->content); + $contents = question::parse_choice_content($choice->content); $checked = false; - if (!empty($response->answers[$this->id])) { - $checked = isset($response->answers[$this->id][$id]); + if (!empty($response->answers[$this->id()])) { + $checked = isset($response->answers[$this->id()][$id]); } - $checkbox->name = 'q' . $this->id . '[' . $id . ']'; + $checkbox->name = 'q' . $this->id() . '[' . $id . ']'; $checkbox->value = $id; $checkbox->id = 'checkbox_' . $id; $checkbox->label = format_text($contents->text, FORMAT_HTML, ['noclean' => true]) . $contents->image; @@ -142,9 +142,9 @@ protected function question_survey_display($response, $dependants, $blankquestio $checkbox->checked = $checked; } if ($choice->is_other_choice()) { - $checkbox->oname = 'q' . $this->id . '[' . $choice->other_choice_name() . ']'; - $checkbox->ovalue = (isset($response->answers[$this->id][$id]) && !empty($response->answers[$this->id][$id]) ? - format_string(stripslashes($response->answers[$this->id][$id]->value)) : ''); + $checkbox->oname = 'q' . $this->id() . '[' . $choice->other_choice_name() . ']'; + $checkbox->ovalue = (isset($response->answers[$this->id()][$id]) && !empty($response->answers[$this->id()][$id]) ? + format_string(stripslashes($response->answers[$this->id()][$id]->value)) : ''); $checkbox->label = format_text($choice->other_choice_display() . '', FORMAT_HTML, ['noclean' => true]); } if (!empty($this->qlegend)) { @@ -160,7 +160,7 @@ protected function question_survey_display($response, $dependants, $blankquestio /** * Return the context tags for the check response template. - * @param \mod_questionnaire\responsetype\response\response $response + * @param \mod_questionnaire\local\response\response $response * @return \stdClass The check question response context tags. */ protected function response_survey_display($response) { @@ -169,16 +169,16 @@ protected function response_survey_display($response) { $resptags = new \stdClass(); $resptags->choices = []; - if (!isset($response->answers[$this->id])) { - $response->answers[$this->id][] = new \mod_questionnaire\responsetype\answer\answer(); + if (!isset($response->answers[$this->id()])) { + $response->answers[$this->id()][] = new \mod_questionnaire\local\response\answer\answer(); } foreach ($this->choices as $id => $choice) { $chobj = new \stdClass(); if (!$choice->is_other_choice()) { - $contents = questionnaire_choice_values($choice->content); + $contents = question::parse_choice_content($choice->content); $choice->content = $contents->text . $contents->image; - if (isset($response->answers[$this->id][$id])) { + if (isset($response->answers[$this->id()][$id])) { $chobj->selected = 1; } $chobj->name = $id . $uniquetag++; @@ -191,8 +191,8 @@ protected function response_survey_display($response) { ); } else { $othertext = $choice->other_choice_display(); - if (isset($response->answers[$this->id][$id])) { - $oresp = $response->answers[$this->id][$id]->value; + if (isset($response->answers[$this->id()][$id])) { + $oresp = $response->answers[$this->id()][$id]->value; $chobj->selected = 1; $chobj->othercontent = (!empty($oresp) ? htmlspecialchars($oresp) : ' '); } @@ -215,10 +215,10 @@ protected function response_survey_display($response) { */ public function response_complete($responsedata) { if ( - isset($responsedata->{'q' . $this->id}) && $this->required() && - is_array($responsedata->{'q' . $this->id}) + isset($responsedata->{'q' . $this->id()}) && $this->required() && + is_array($responsedata->{'q' . $this->id()}) ) { - foreach ($responsedata->{'q' . $this->id} as $key => $choice) { + foreach ($responsedata->{'q' . $this->id()} as $key => $choice) { // If only an 'other' choice is selected and empty, question is not completed. if ((strpos($key, 'o') === 0) && empty($choice)) { return false; @@ -239,10 +239,10 @@ public function response_complete($responsedata) { public function response_valid($responsedata) { $nbrespchoices = 0; $valid = true; - if (is_a($responsedata, 'mod_questionnaire\responsetype\response\response')) { + if (is_a($responsedata, 'mod_questionnaire\local\response\response')) { // If $responsedata is a response object, look through the answers. - if (isset($responsedata->answers[$this->id]) && !empty($responsedata->answers[$this->id])) { - foreach ($responsedata->answers[$this->id] as $answer) { + if (isset($responsedata->answers[$this->id()]) && !empty($responsedata->answers[$this->id()])) { + foreach ($responsedata->answers[$this->id()] as $answer) { if (isset($this->choices[$answer->choiceid]) && $this->choices[$answer->choiceid]->is_other_choice()) { $valid = !empty($answer->value); } else { @@ -250,12 +250,12 @@ public function response_valid($responsedata) { } } } - } else if (isset($responsedata->{'q' . $this->id})) { - foreach ($responsedata->{'q' . $this->id} as $key => $answer) { + } else if (isset($responsedata->{'q' . $this->id()})) { + foreach ($responsedata->{'q' . $this->id()} as $key => $answer) { if (strpos($key, 'o') === 0) { // ..."other" choice is checked but text box is empty. $okey = substr($key, 1); - if (isset($responsedata->{'q' . $this->id}[$okey]) && empty(trim($answer))) { + if (isset($responsedata->{'q' . $this->id()}[$okey]) && empty(trim($answer))) { $valid = false; break; } @@ -268,8 +268,8 @@ public function response_valid($responsedata) { } $nbquestchoices = count($this->choices); - $min = $this->length; - $max = $this->precise; + $min = $this->length(); + $max = $this->precise(); if ($max == 0) { $max = $nbquestchoices; } @@ -375,8 +375,8 @@ public function mobile_question_choices_display() { */ public function get_mobile_response_data($response) { $resultdata = []; - if (isset($response->answers[$this->id])) { - foreach ($response->answers[$this->id] as $answer) { + if (isset($response->answers[$this->id()])) { + foreach ($response->answers[$this->id()] as $answer) { if (isset($this->choices[$answer->choiceid])) { // Add a fieldkey for each choice. $resultdata[$this->mobile_fieldkey($answer->choiceid)] = 1; diff --git a/classes/question/choice.php b/classes/local/question/choice.php similarity index 86% rename from classes/question/choice.php rename to classes/local/question/choice.php index 663e074a1..d5d091305 100644 --- a/classes/question/choice.php +++ b/classes/local/question/choice.php @@ -14,7 +14,7 @@ // You should have received a copy of the GNU General Public License // along with Moodle. If not, see . -namespace mod_questionnaire\question; +namespace mod_questionnaire\local\question; /** * This defines a structured class to hold question choices. @@ -63,15 +63,11 @@ public function __construct($id = null, $questionid = null, $content = null, $va * @return choice */ public static function create_from_id($id) { - global $DB; - - // Rename the data field question_id to questionid to conform with code conventions. Eventually, data table should be - // changed. - if ($record = $DB->get_record(self::tablename(), ['id' => $id], 'id,question_id as questionid,content,value')) { - return new choice($id, $record->questionid, $record->content, $record->value); - } else { - return new choice(); + $choicerec = \mod_questionnaire\local\db\choice_record::get_record(['id' => $id]); + if ($choicerec) { + return new choice($id, $choicerec->get('questionid'), $choicerec->get('content'), $choicerec->get('value')); } + return new choice(); } /** @@ -91,11 +87,6 @@ public static function create_from_data($choicedata) { $choicedata[$property] = null; } } - // Since the data table uses 'question_id' instead of 'questionid', look for that field as well. Hack that should be fixed - // by renaming the data table column. - if (!empty($choicedata['question_id'])) { - $choicedata['questionid'] = $choicedata['question_id']; - } return new choice($choicedata['id'], $choicedata['questionid'], $choicedata['content'], $choicedata['value']); } @@ -113,8 +104,12 @@ public static function tablename() { * @return bool */ public static function delete_from_db_by_id($id) { - global $DB; - return $DB->delete_records(self::tablename(), ['id' => $id]); + $choicerec = \mod_questionnaire\local\db\choice_record::get_record(['id' => $id]); + if ($choicerec) { + $choicerec->delete(); + return true; + } + return false; } /** diff --git a/classes/question/date.php b/classes/local/question/date.php similarity index 85% rename from classes/question/date.php rename to classes/local/question/date.php index bf8863a01..8fec8a478 100644 --- a/classes/question/date.php +++ b/classes/local/question/date.php @@ -14,7 +14,7 @@ // You should have received a copy of the GNU General Public License // along with Moodle. If not, see . -namespace mod_questionnaire\question; +namespace mod_questionnaire\local\question; /** * This file contains the parent class for date question types. @@ -30,7 +30,7 @@ class date extends question { * @return string */ protected function responseclass() { - return '\\mod_questionnaire\\responsetype\\date'; + return '\\mod_questionnaire\\local\\response\\date'; } /** @@ -59,7 +59,7 @@ public function response_template() { /** * Return the context tags for the check question template. - * @param \mod_questionnaire\responsetype\response\response $response + * @param \mod_questionnaire\local\response\response $response * @param array $descendantsdata * @param boolean $blankquestionnaire * @return object The check question context tags. @@ -67,21 +67,21 @@ public function response_template() { protected function question_survey_display($response, $descendantsdata, $blankquestionnaire = false) { // Date. $questiontags = new \stdClass(); - if (!empty($response->answers[$this->id])) { - $dateentered = $response->answers[$this->id][0]->value; + if (!empty($response->answers[$this->id()])) { + $dateentered = $response->answers[$this->id()][0]->value; $setdate = $this->check_date_format($dateentered); if (!$setdate) { $msg = get_string('wrongdateformat', 'questionnaire', $dateentered); $this->add_notification($msg); } else { - $response->answers[$this->id][0]->value = $dateentered; + $response->answers[$this->id()][0]->value = $dateentered; } } $choice = new \stdClass(); $choice->type = 'date'; // Using HTML5 date input. $choice->onkeypress = 'return event.keyCode != 13;'; - $choice->name = 'q' . $this->id; - $choice->value = (isset($response->answers[$this->id][0]->value) ? $response->answers[$this->id][0]->value : ''); + $choice->name = 'q' . $this->id(); + $choice->value = (isset($response->answers[$this->id()][0]->value) ? $response->answers[$this->id()][0]->value : ''); $questiontags->qelements = new \stdClass(); $questiontags->qelements->choice = $choice; $questiontags->isprint = $this->get_isprint(); @@ -90,13 +90,13 @@ protected function question_survey_display($response, $descendantsdata, $blankqu /** * Return the context tags for the check response template. - * @param \mod_questionnaire\responsetype\response\response $response + * @param \mod_questionnaire\local\response\response $response * @return object The check question response context tags. */ protected function response_survey_display($response) { $resptags = new \stdClass(); - if (isset($response->answers[$this->id])) { - $answer = reset($response->answers[$this->id]); + if (isset($response->answers[$this->id()])) { + $answer = reset($response->answers[$this->id()]); $resptags->content = $answer->value; } return $resptags; @@ -110,14 +110,14 @@ protected function response_survey_display($response) { */ public function response_valid($responsedata) { $responseval = false; - if (is_a($responsedata, 'mod_questionnaire\responsetype\response\response')) { + if (is_a($responsedata, 'mod_questionnaire\local\response\response')) { // If $responsedata is a response object, look through the answers. - if (isset($responsedata->answers[$this->id]) && !empty($responsedata->answers[$this->id])) { - $answer = $responsedata->answers[$this->id][0]; + if (isset($responsedata->answers[$this->id()]) && !empty($responsedata->answers[$this->id()])) { + $answer = $responsedata->answers[$this->id()][0]; $responseval = $answer->value; } - } else if (isset($responsedata->{'q' . $this->id})) { - $responseval = $responsedata->{'q' . $this->id}; + } else if (isset($responsedata->{'q' . $this->id()})) { + $responseval = $responsedata->{'q' . $this->id()}; } if ($responseval !== false) { $checkdateresult = true; @@ -223,8 +223,8 @@ public function mobile_question_choices_display() { $choices = []; $choices[0] = new \stdClass(); $choices[0]->id = 0; - $choices[0]->choice_id = 0; - $choices[0]->question_id = $this->id; + $choices[0]->choiceid = 0; + $choices[0]->questionid = $this->id(); $choices[0]->content = ''; $choices[0]->value = null; return $choices; diff --git a/classes/question/drop.php b/classes/local/question/drop.php similarity index 85% rename from classes/question/drop.php rename to classes/local/question/drop.php index f611aaad7..7dbba666a 100644 --- a/classes/question/drop.php +++ b/classes/local/question/drop.php @@ -14,10 +14,10 @@ // You should have received a copy of the GNU General Public License // along with Moodle. If not, see . -namespace mod_questionnaire\question; +namespace mod_questionnaire\local\question; use html_writer; -use mod_questionnaire\question\choice; -use mod_questionnaire\responsetype\response\response; +use mod_questionnaire\local\question\choice; +use mod_questionnaire\local\response\response; /** * This file contains the parent class for drop question types. @@ -33,7 +33,7 @@ class drop extends question { * @return string The response object based off of questionnaire_response_base. */ protected function responseclass() { - return '\\mod_questionnaire\\responsetype\\single'; + return '\\mod_questionnaire\\local\\response\\single'; } /** @@ -85,7 +85,7 @@ public function supports_feedback() { /** * Return the context tags for the check question template. - * @param \mod_questionnaire\responsetype\response\response $response + * @param \mod_questionnaire\local\response\response $response * @param array $dependants Array of all questions/choices depending on this question. * @param boolean $blankquestionnaire * @return object The check question context tags. @@ -105,15 +105,15 @@ protected function question_survey_display($response, $dependants, $blankquestio $option = new \stdClass(); $option->value = $key; $option->label = format_string($choice->content); - if (isset($response->answers[$this->id][$key])) { + if (isset($response->answers[$this->id()][$key])) { $option->selected = true; } $options[] = $option; } $chobj = new \stdClass(); - $chobj->name = 'q' . $this->id; - $chobj->id = self::qtypename($this->type_id) . $this->name; - $chobj->class = 'select form-select menu q' . $this->id; + $chobj->name = 'q' . $this->id(); + $chobj->id = self::qtypename($this->typeid()) . $this->name(); + $chobj->class = 'select form-select menu q' . $this->id(); $chobj->options = $options; $choicetags->qelements->choice = $chobj; $choicetags->isprint = $this->get_isprint(); @@ -123,29 +123,29 @@ protected function question_survey_display($response, $dependants, $blankquestio /** * Return the context tags for the drop response template. - * @param \mod_questionnaire\responsetype\response\response $response + * @param \mod_questionnaire\local\response\response $response * @return \stdClass The check question response context tags. */ protected function response_survey_display($response) { static $uniquetag = 0; // To make sure all radios have unique names. $resptags = new \stdClass(); - $resptags->name = 'q' . $this->id . $uniquetag++; + $resptags->name = 'q' . $this->id() . $uniquetag++; $resptags->id = 'menu' . $resptags->name; $resptags->class = 'select form-select ' . $resptags->id; $resptags->options = []; $resptags->options[] = (object)['value' => '', 'label' => get_string('choosedots')]; - if (!isset($response->answers[$this->id])) { - $response->answers[$this->id][] = new \mod_questionnaire\responsetype\answer\answer(); + if (!isset($response->answers[$this->id()])) { + $response->answers[$this->id()][] = new \mod_questionnaire\local\response\answer\answer(); } foreach ($this->choices as $id => $choice) { - $contents = questionnaire_choice_values($choice->content); + $contents = question::parse_choice_content($choice->content); $chobj = new \stdClass(); $chobj->value = $id; $chobj->label = format_text($contents->text, FORMAT_HTML, ['noclean' => true]); - if (isset($response->answers[$this->id][$id])) { + if (isset($response->answers[$this->id()][$id])) { $chobj->selected = 1; $resptags->selectedlabel = $chobj->label; } @@ -200,8 +200,8 @@ public function mobile_question_display($qnum, $autonum = false) { */ public function get_mobile_response_data($response) { $resultdata = []; - if (isset($response->answers[$this->id])) { - foreach ($response->answers[$this->id] as $answer) { + if (isset($response->answers[$this->id()])) { + foreach ($response->answers[$this->id()] as $answer) { // Add a fieldkey for each choice. $resultdata[$this->mobile_fieldkey()] = $answer->choiceid; } diff --git a/classes/question/essay.php b/classes/local/question/essay.php similarity index 87% rename from classes/question/essay.php rename to classes/local/question/essay.php index 45e0cc955..446a68c5e 100644 --- a/classes/question/essay.php +++ b/classes/local/question/essay.php @@ -14,9 +14,9 @@ // You should have received a copy of the GNU General Public License // along with Moodle. If not, see . -namespace mod_questionnaire\question; +namespace mod_questionnaire\local\question; use html_writer; -use mod_questionnaire\responsetype\response\response; +use mod_questionnaire\local\response\response; /** * This file contains the parent class for essay question types. @@ -32,7 +32,7 @@ class essay extends text { * @return object The response object based off of questionnaire_response_base. */ protected function responseclass() { - return '\\mod_questionnaire\\responsetype\\text'; + return '\\mod_questionnaire\\local\\response\\text'; } /** @@ -74,23 +74,23 @@ protected function question_survey_display($response, $descendantsdata, $blankqu $cols = 80; $rows = 15; // Use HTML editor or not? - if ($this->precise == 0) { + if ($this->precise() == 0) { $canusehtmleditor = true; - $rows = $this->length == 0 ? $rows : $this->length; + $rows = $this->length() == 0 ? $rows : $this->length(); } else { $canusehtmleditor = false; // Prior to version 2.6, "precise" was used for rows number. - $rows = $this->precise > 1 ? $this->precise : $this->length; + $rows = $this->precise() > 1 ? $this->precise() : $this->length(); } - $name = 'q' . $this->id; - if (isset($response->answers[$this->id][0])) { - $value = $response->answers[$this->id][0]->value; + $name = 'q' . $this->id(); + if (isset($response->answers[$this->id()][0])) { + $value = $response->answers[$this->id()][0]->value; } else { $value = ''; } if ($canusehtmleditor && !$this->get_isprint()) { $editor = editors_get_preferred_editor(); - $editor->use_editor($name, questionnaire_get_editor_options($this->context)); + $editor->use_editor($name, \mod_questionnaire\form_options::editor($this->context)); $texteditor = html_writer::tag( 'textarea', $value, @@ -114,8 +114,8 @@ protected function question_survey_display($response, $descendantsdata, $blankqu * */ protected function response_survey_display($response) { - if (isset($response->answers[$this->id])) { - $answer = reset($response->answers[$this->id]); + if (isset($response->answers[$this->id()])) { + $answer = reset($response->answers[$this->id()]); $answer = format_text($answer->value, FORMAT_HTML); } else { $answer = ' '; diff --git a/classes/question/file.php b/classes/local/question/file.php similarity index 87% rename from classes/question/file.php rename to classes/local/question/file.php index 5622b4d48..375c6f763 100644 --- a/classes/question/file.php +++ b/classes/local/question/file.php @@ -14,10 +14,10 @@ // You should have received a copy of the GNU General Public License // along with Moodle. If not, see . -namespace mod_questionnaire\question; +namespace mod_questionnaire\local\question; use core_media_manager; use form_filemanager; -use mod_questionnaire\responsetype\response\response; +use mod_questionnaire\local\response\response; use moodle_url; use MoodleQuickForm; @@ -64,7 +64,7 @@ public function response_template() { * @return object|string */ protected function responseclass() { - return '\\mod_questionnaire\\responsetype\\file'; + return '\\mod_questionnaire\\local\\response\\file'; } /** @@ -79,14 +79,14 @@ protected function question_survey_display($response, $descendantsdata, $blankqu global $CFG, $PAGE; require_once($CFG->libdir . '/filelib.php'); - $elname = 'q' . $this->id; + $elname = 'q' . $this->id(); // If there is a response and the response is resumed, get the original itemid. $itemid = ( - isset($response->answers[$this->id]) && - !empty($response->answers[$this->id]) && + isset($response->answers[$this->id()]) && + !empty($response->answers[$this->id()]) && isset($_REQUEST['resume']) && $_REQUEST['resume'] === '1' - ) ? (int)$response->answers[$this->id][0]->id : 0; + ) ? (int)$response->answers[$this->id()][0]->id : 0; // Prepare the draft area. $draftitemid = file_get_submitted_draft_itemid($elname); file_prepare_draft_area( @@ -117,20 +117,20 @@ public function response_complete($responsedata) { $answered = false; // If $responsedata is a response object, look through the answers. if ( - is_a($responsedata, 'mod_questionnaire\responsetype\response\response') && - isset($responsedata->answers[$this->id]) && - !empty($responsedata->answers[$this->id]) + is_a($responsedata, 'mod_questionnaire\local\response\response') && + isset($responsedata->answers[$this->id()]) && + !empty($responsedata->answers[$this->id()]) ) { - $answer = reset($responsedata->answers[$this->id]); + $answer = reset($responsedata->answers[$this->id()]); $answered = ((int)$answer->value > 0); - } else if (isset($responsedata->{'q' . $this->id})) { // If $responsedata is webform data, check that it is not empty. - $draftitemid = (int)$responsedata->{'q' . $this->id}; + } else if (isset($responsedata->{'q' . $this->id()})) { // If $responsedata is webform data, check that it is not empty. + $draftitemid = (int)$responsedata->{'q' . $this->id()}; if ($draftitemid > 0) { $info = file_get_draft_area_info($draftitemid); $answered = $info['filecount'] > 0; } } - return !($this->required() && ($this->deleted == 'n') && !$answered); + return !($this->required() && ($this->deleted() == 'n') && !$answered); } /** @@ -159,8 +159,8 @@ protected function response_survey_display($data) { global $PAGE, $CFG; require_once($CFG->libdir . '/filelib.php'); require_once($CFG->libdir . '/resourcelib.php'); - if (isset($data->answers[$this->id])) { - $answer = reset($data->answers[$this->id]); + if (isset($data->answers[$this->id()])) { + $answer = reset($data->answers[$this->id()]); } else { return ''; } diff --git a/classes/question/numerical.php b/classes/local/question/numerical.php similarity index 80% rename from classes/question/numerical.php rename to classes/local/question/numerical.php index 19f8318f5..bb42bd3dd 100644 --- a/classes/question/numerical.php +++ b/classes/local/question/numerical.php @@ -14,7 +14,7 @@ // You should have received a copy of the GNU General Public License // along with Moodle. If not, see . -namespace mod_questionnaire\question; +namespace mod_questionnaire\local\question; /** * This file contains the parent class for numeric question types. @@ -33,7 +33,7 @@ class numerical extends question { * @param array $params */ public function __construct($id = 0, $question = null, $context = null, $params = []) { - $this->length = 10; + $this->set_length(10); return parent::__construct($id, $question, $context, $params); } @@ -44,7 +44,7 @@ public function __construct($id = 0, $question = null, $context = null, $params * */ protected function responseclass() { - return '\\mod_questionnaire\\responsetype\\numericaltext'; + return '\\mod_questionnaire\\local\\response\\numericaltext'; } /** @@ -73,7 +73,7 @@ public function response_template() { /** * Return the context tags for the check question template. - * @param \mod_questionnaire\responsetype\response\response $response + * @param \mod_questionnaire\local\response\response $response * @param array $descendantsdata * @param boolean $blankquestionnaire * @return \stdClass The check question context tags. @@ -81,10 +81,10 @@ public function response_template() { protected function question_survey_display($response, $descendantsdata, $blankquestionnaire = false) { // Numeric. $questiontags = new \stdClass(); - $precision = $this->precise; + $precision = $this->precise(); $a = new \stdClass(); - if (isset($response->answers[$this->id][0])) { - $mynumber = $response->answers[$this->id][0]->value; + if (isset($response->answers[$this->id()][0])) { + $mynumber = $response->answers[$this->id()][0]->value; if ($mynumber != '') { $mynumber0 = $mynumber; if (!is_numeric($mynumber)) { @@ -94,11 +94,11 @@ protected function question_survey_display($response, $descendantsdata, $blankqu if ($precision) { $pos = strpos($mynumber, '.'); if (!$pos) { - if (strlen($mynumber) > $this->length) { - $mynumber = substr($mynumber, 0, $this->length); + if (strlen($mynumber) > $this->length()) { + $mynumber = substr($mynumber, 0, $this->length()); } } - $this->length += (1 + $precision); // To allow for n numbers after decimal point. + $this->set_length($this->length() + (1 + $precision)); // To allow for n numbers after decimal point. } $mynumber = number_format($mynumber, $precision, '.', ''); if ($mynumber != $mynumber0) { @@ -110,19 +110,19 @@ protected function question_survey_display($response, $descendantsdata, $blankqu } } if ($mynumber != '') { - $response->answers[$this->id][0]->value = $mynumber; + $response->answers[$this->id()][0]->value = $mynumber; } } $choice = new \stdClass(); $choice->onkeypress = 'return event.keyCode != 13;'; - $choice->size = $this->length; + $choice->size = $this->length(); // Add a 'thousands separator' instruction if there is a size setting greater than three. $choice->instruction = (empty($choice->size) || ($choice->size > 3)) ? get_string('thousands', 'mod_questionnaire') : ''; - $choice->name = 'q' . $this->id; - $choice->maxlength = $this->length; - $choice->value = (isset($response->answers[$this->id][0]) ? $response->answers[$this->id][0]->value : ''); - $choice->id = self::qtypename($this->type_id) . $this->id; + $choice->name = 'q' . $this->id(); + $choice->maxlength = $this->length(); + $choice->value = (isset($response->answers[$this->id()][0]) ? $response->answers[$this->id()][0]->value : ''); + $choice->id = self::qtypename($this->typeid()) . $this->id(); $questiontags->qelements = new \stdClass(); $questiontags->qelements->choice = $choice; return $questiontags; @@ -136,14 +136,14 @@ protected function question_survey_display($response, $descendantsdata, $blankqu */ public function response_valid($responsedata) { $responseval = false; - if (is_a($responsedata, 'mod_questionnaire\responsetype\response\response')) { + if (is_a($responsedata, 'mod_questionnaire\local\response\response')) { // If $responsedata is a response object, look through the answers. - if (isset($responsedata->answers[$this->id]) && !empty($responsedata->answers[$this->id])) { - $answer = $responsedata->answers[$this->id][0]; + if (isset($responsedata->answers[$this->id()]) && !empty($responsedata->answers[$this->id()])) { + $answer = $responsedata->answers[$this->id()][0]; $responseval = $answer->value; } - } else if (isset($responsedata->{'q' . $this->id})) { - $responseval = $responsedata->{'q' . $this->id}; + } else if (isset($responsedata->{'q' . $this->id()})) { + $responseval = $responsedata->{'q' . $this->id()}; } if ($responseval !== false) { // If commas are present, replace them with periods, in case that was meant as the European decimal place. @@ -156,13 +156,13 @@ public function response_valid($responsedata) { /** * Return the context tags for the numeric response template. - * @param \mod_questionnaire\responsetype\response\response $response + * @param \mod_questionnaire\local\response\response $response * @return \stdClass The numeric question response context tags. */ protected function response_survey_display($response) { $resptags = new \stdClass(); - if (isset($response->answers[$this->id])) { - $answer = reset($response->answers[$this->id]); + if (isset($response->answers[$this->id()])) { + $answer = reset($response->answers[$this->id()]); $resptags->content = $answer->value; } return $resptags; @@ -174,7 +174,7 @@ protected function response_survey_display($response) { * @param string $helptext */ protected function form_length(\MoodleQuickForm $mform, $helptext = '') { - $this->length = isset($this->length) ? $this->length : 10; + $this->set_length($this->length() ?: 10); return parent::form_length($mform, 'maxdigitsallowed'); } @@ -215,8 +215,8 @@ public function mobile_question_choices_display() { $choices = []; $choices[0] = new \stdClass(); $choices[0]->id = 0; - $choices[0]->choice_id = 0; - $choices[0]->question_id = $this->id; + $choices[0]->choiceid = 0; + $choices[0]->questionid = $this->id(); $choices[0]->content = ''; $choices[0]->value = null; return $choices; diff --git a/classes/question/pagebreak.php b/classes/local/question/pagebreak.php similarity index 81% rename from classes/question/pagebreak.php rename to classes/local/question/pagebreak.php index 596de4bc5..112d6d2a4 100644 --- a/classes/question/pagebreak.php +++ b/classes/local/question/pagebreak.php @@ -14,9 +14,9 @@ // You should have received a copy of the GNU General Public License // along with Moodle. If not, see . -namespace mod_questionnaire\question; +namespace mod_questionnaire\local\question; use mod_questionnaire\edit_question_form; -use questionnaire; +use mod_questionnaire\questionnaire; /** * This file contains the parent class for pagebreak question types. @@ -46,11 +46,18 @@ public function helpname() { /** * Get the output for the start of the questions in a survey. * @param int $qnum - * @param \mod_questionnaire\responsetype\response\response $response + * @param \mod_questionnaire\local\response\response $response + * @param \questionnaire|null $questionnaire + * @param bool $individualresponse Unused; signature must match parent. * @return \stdClass */ - public function questionstart_survey_display($qnum, $response = null) { - return ''; + public function questionstart_survey_display( + $qnum, + $response = null, + $questionnaire = null, + bool $individualresponse = false + ): \stdClass { + return new \stdClass(); } /** @@ -77,10 +84,10 @@ protected function response_survey_display($data) { * Override this, or any of the internal methods, to provide specific form data for editing the question type. * The structure of the elements here is the default layout for the question form. * @param edit_question_form $form The main moodleform object. - * @param questionnaire $questionnaire The questionnaire being edited. + * @param \mod_questionnaire\questionnaire $questionnaire The questionnaire being edited. * @return bool */ - public function edit_form(edit_question_form $form, questionnaire $questionnaire) { + public function edit_form(edit_question_form $form, \mod_questionnaire\questionnaire $questionnaire) { return false; } diff --git a/classes/question/question.php b/classes/local/question/question.php similarity index 67% rename from classes/question/question.php rename to classes/local/question/question.php index 0ae9937a3..28b5ceeaa 100644 --- a/classes/question/question.php +++ b/classes/local/question/question.php @@ -23,11 +23,15 @@ * @package mod_questionnaire */ -namespace mod_questionnaire\question; +namespace mod_questionnaire\local\question; use mod_questionnaire\edit_question_form; -use mod_questionnaire\responsetype\response\response; -use questionnaire; +use mod_questionnaire\local\db\question_record; +use mod_questionnaire\local\db\question_type_record; +use mod_questionnaire\local\question_type; +use mod_questionnaire\local\response\response; +use mod_questionnaire\questionnaire; use html_writer; +use stdClass; defined('MOODLE_INTERNAL') || die(); @@ -47,12 +51,9 @@ define('QUESPAGEBREAK', 99); define('QUESSECTIONTEXT', 100); -global $idcounter, $CFG; +global $idcounter; $idcounter = 0; -require_once($CFG->dirroot . '/mod/questionnaire/locallib.php'); - -#[\AllowDynamicProperties] /** * Class for describing a question * @@ -62,17 +63,33 @@ */ abstract class question { // Class Properties. - /** @var int $id The database id of this question. */ - public $id = 0; - /** @var int $surveyid The database id of the survey this question belongs to. */ - public $surveyid = 0; + /** + * Backing persistent record for all DB columns (surveyid, name, typeid, length, + * precise, position, content, required, deleted, extradata, resultid). + * Null only briefly before the constructor initialises it; set to a new empty + * record in __set so that subclass pre-constructor default assignments are buffered. + * + * @var question_record|null + */ + protected ?question_record $record = null; - /** @var string $name The name of this question. */ - public $name = ''; + /** + * The question_type for this question (loaded from questionnaire_question_type table). + * Provides $type (name string), $haschoices and $responsetable. + * + * @var question_type|null + */ + protected ?question_type $questiontype = null; - /** @var string $type The name of the question type. */ - public $type = ''; + /** + * Buffer for DB-column values assigned by subclass constructors (via set_length() etc.) + * before parent::__construct() has had a chance to initialise $this->record. + * Applied to the record for new questions only; ignored when loading from DB. + * + * @var array + */ + private array $preinit = []; /** @var array $choices Array holding any choices for this question. */ public $choices = []; @@ -80,59 +97,54 @@ abstract class question { /** @var array $dependencies Array holding any dependencies for this question. */ public $dependencies = []; - /** @var string $responsetable The table name for responses. */ - public $responsetable = ''; + /** @var string $qlegend The question's legend. */ + public $qlegend = ''; + + /** @var string $allchoices The list of all question's choices. */ + public $allchoices = ''; + + /** @var bool $isprint The isprint flag. */ + public $isprint = false; - /** @var int $length The length field. */ - public $length = 0; + /** @var \context|null $context The module context. */ + public $context = null; - /** @var int $precise The precision field. */ - public $precise = 0; + /** @var object|null $responsetype The response type handler for this question. */ + public $responsetype = null; - /** @var int $position Position in the questionnaire */ - public $position = 0; + /** @var int|null $qid The id assigned after inserting a new question record. */ + public $qid = null; - /** @var string $content The question's content. */ - public $content = ''; + /** @var mixed $resultid Legacy response result id (not loaded from DB; set transiently). */ + public $resultid = null; - /** @var string $qlegend The question's legend. */ - public $qlegend = ''; + /** @var mixed $dependquestion Legacy dependency question id. */ + public $dependquestion = null; - /** @var string $allchoices The list of all question's choices. */ - public $allchoices = ''; + /** @var mixed $dependchoice Legacy dependency choice id. */ + public $dependchoice = null; - /** @var bool $required The required flag. */ - public $required = 'n'; + /** @var int|null $sid The survey id, set by locallib when building question edit forms. */ + public $sid = null; - /** @var int $deleted The deleted flag. */ - public $deleted = null; + /** @var array $dependquestionsand Dependency question+choice ids for "and" logic (locallib form builder). */ + public $dependquestionsand = []; - /** @var mixed $extradata Any custom data for the question type. */ - public $extradata = ''; + /** @var array $dependlogicand Dependency logic values for "and" conditions (locallib form builder). */ + public $dependlogicand = []; - /** @var bool $isprint The isprint flag. */ - public $isprint = false; + /** @var array $dependquestionsor Dependency question+choice ids for "or" logic (locallib form builder). */ + public $dependquestionsor = []; - /** @var array $qtypenames List of all question names. */ - private static $qtypenames = [ - QUESYESNO => 'yesno', - QUESTEXT => 'text', - QUESESSAY => 'essay', - QUESRADIO => 'radio', - QUESCHECK => 'check', - QUESDROP => 'drop', - QUESRATE => 'rate', - QUESDATE => 'date', - QUESFILE => 'file', - QUESNUMERIC => 'numerical', - QUESPAGEBREAK => 'pagebreak', - QUESSECTIONTEXT => 'sectiontext', - QUESSLIDER => 'slider', - ]; + /** @var array $dependlogicor Dependency logic values for "or" conditions (locallib form builder). */ + public $dependlogicor = []; /** @var array $notifications Array of extra messages for display purposes. */ private $notifications = []; + /** @var \questionnaire|null The parent questionnaire object, set during rendering. */ + protected $questionnaire = null; + // Class Methods. /** @@ -143,57 +155,339 @@ abstract class question { * @param array $params */ public function __construct($id = 0, $question = null, $context = null, $params = []) { - global $DB; - static $qtypes = null; - - if ($qtypes === null) { - $qtypes = $DB->get_records( - 'questionnaire_question_type', - [], - 'typeid', - 'typeid, type, has_choices, response_table' - ) ?? []; - } - + // Determine which DB-backed properties were pre-set by a subclass constructor + // before this parent constructor was called (e.g. text sets length=20, rate sets + // length=5). Those are held in $this->preinit (see __set) and applied below only + // when constructing a brand-new question with no stored data. if ($id) { - $question = $DB->get_record('questionnaire_question', ['id' => $id]); + // Load existing question from the database. + $loaded = question_record::get_record(['id' => $id]); + $this->record = $loaded ?: new question_record(); + } else if (is_object($question)) { + // Construct from a supplied stdClass (e.g. passed in from a list query). + $this->record = new question_record(0, $question); + } else { + // Brand-new question; start with an empty record and apply any subclass + // defaults that were buffered in $this->preinit before this constructor ran. + $this->record = new question_record(); + foreach ($this->preinit as $field => $default) { + $this->record->set($field, $default); + } } + $this->preinit = []; - if (is_object($question)) { - $this->id = $question->id; - $this->surveyid = $question->surveyid; - $this->name = $question->name; - $this->length = $question->length; - $this->precise = $question->precise; - $this->position = $question->position; - $this->content = $question->content; - $this->required = $question->required; - $this->deleted = $question->deleted; - $this->extradata = $question->extradata; + // Apply caller-supplied overrides ($params keys map to DB columns or declared props). + $dbfields = ['surveyid', 'name', 'typeid', 'length', 'precise', + 'position', 'content', 'required', 'deleted', 'extradata']; + foreach ($params as $property => $value) { + if ($property === 'id') { + $stub = new \stdClass(); + $stub->id = (int)$value; + $this->record->from_record($stub); + } else if (in_array($property, $dbfields, true)) { + $this->record->set($property, $value); + } else if (property_exists($this, $property)) { + $this->$property = $value; + } + } - $this->type_id = $question->type_id; - $this->type = $qtypes[$this->type_id]->type; - $this->responsetable = $qtypes[$this->type_id]->response_table; + // Resolve the question_type (cached per typeid for the request lifetime). + $typeid = $this->record->get('typeid'); + if ($typeid) { + $this->questiontype = question_type::from_typeid($typeid); + } + // Load choices and dependencies when there is a persisted record to query. + if ($id || is_object($question)) { if (!empty($question->choices)) { $this->choices = $question->choices; - } else if ($qtypes[$this->type_id]->has_choices == 'y') { + } else if ($this->questiontype && $this->questiontype->haschoices === 'y') { $this->get_choices(); } - // Added for dependencies. $this->get_dependencies(); } - $this->context = $context; - foreach ($params as $property => $value) { - $this->$property = $value; - } + $this->context = $context; if ($respclass = $this->responseclass()) { $this->responsetype = new $respclass($this); } } + // Named accessors for DB-backed fields. + + /** + * Get the question id. + * @return int + */ + public function id(): int { + return $this->record ? (int)$this->record->get('id') : 0; + } + + /** + * Get the survey id this question belongs to. + * @return int + */ + public function surveyid(): int { + return $this->record ? (int)$this->record->get('surveyid') : 0; + } + + /** + * Get the question name. + * @return string + */ + public function name(): string { + return $this->record ? (string)$this->record->get('name') : ''; + } + + /** + * Get the question type id. + * @return int + */ + public function typeid(): int { + return $this->record ? (int)$this->record->get('typeid') : 0; + } + + /** + * Get the question type name string (e.g. 'radio', 'check'), or empty string if not set. + * @return string + */ + public function type(): string { + return $this->questiontype ? $this->questiontype->type : ''; + } + + /** + * Get the response table name for this question type, or null if not applicable. + * @return string|null + */ + public function responsetable(): ?string { + return $this->questiontype ? $this->questiontype->responsetable : null; + } + + /** + * Get the question length value. + * @return int + */ + public function length(): int { + return $this->record ? (int)$this->record->get('length') : 0; + } + + /** + * Get the question precise value. + * @return int + */ + public function precise(): int { + return $this->record ? (int)$this->record->get('precise') : 0; + } + + /** + * Get the question position. + * @return int + */ + public function position(): int { + return $this->record ? (int)$this->record->get('position') : 0; + } + + /** + * Get the question content string. + * @return string|null + */ + public function content(): ?string { + return $this->record ? (string) $this->record->get('content') : null; + } + + /** + * Get the deleted flag. + * @return string + */ + public function deleted(): string { + return $this->record ? (string)$this->record->get('deleted') : 'n'; + } + + /** + * Get the extradata field. + * @return string|null + */ + public function extradata(): ?string { + return $this->record ? $this->record->get('extradata') : null; + } + + // Named setters for DB-backed fields. + + /** + * Set the question id. + * @param int $value + */ + public function set_id(int $value): void { + $stub = new \stdClass(); + $stub->id = $value; + if ($this->record) { + $this->record->from_record($stub); + } else { + $this->preinit['id'] = $value; + } + } + + /** + * Set the survey id. + * @param int $value + */ + public function set_surveyid(int $value): void { + if ($this->record) { + $this->record->set('surveyid', $value); + } else { + $this->preinit['surveyid'] = $value; + } + } + + /** + * Set the question name. + * @param string $value + */ + public function set_name(string $value): void { + if ($this->record) { + $this->record->set('name', $value); + } else { + $this->preinit['name'] = $value; + } + } + + /** + * Set the question type id. + * @param int $value + */ + public function set_typeid(int $value): void { + if ($this->record) { + $this->record->set('typeid', $value); + } else { + $this->preinit['typeid'] = $value; + } + } + + /** + * Set the question position. + * @param int $value + */ + public function set_position(int $value): void { + if ($this->record) { + $this->record->set('position', $value); + } else { + $this->preinit['position'] = $value; + } + } + + /** + * Set the question content string. + * @param string $value + */ + public function set_content(string $value): void { + if ($this->record) { + $this->record->set('content', $value); + } else { + $this->preinit['content'] = $value; + } + } + + /** + * Set the question length value. + * @param int $value + */ + public function set_length(int $value): void { + if ($this->record) { + $this->record->set('length', $value); + } else { + $this->preinit['length'] = $value; + } + } + + /** + * Set the question precise value. + * @param int $value + */ + public function set_precise(int $value): void { + if ($this->record) { + $this->record->set('precise', $value); + } else { + $this->preinit['precise'] = $value; + } + } + + /** + * Set the deleted flag. + * @param string|null $value + */ + public function set_deleted(?string $value): void { + if ($this->record) { + $this->record->set('deleted', $value); + } else { + $this->preinit['deleted'] = $value; + } + } + + /** + * Set the extradata field. + * @param string|null $value + */ + public function set_extradata(?string $value): void { + if ($this->record) { + $this->record->set('extradata', $value); + } else { + $this->preinit['extradata'] = $value; + } + } + + /** + * Set the required field value in memory only (no DB write). + * Accepts bool or raw 'y'/'n' string. + * @param mixed $value + */ + public function set_required_value(mixed $value): void { + $rval = is_bool($value) ? ($value ? 'y' : 'n') : (string)$value; + if ($this->record) { + $this->record->set('required', $rval); + } else { + $this->preinit['required'] = $rval; + } + } + + /** + * Return a plain stdClass containing all form-relevant question properties. + * + * PHP's (array) cast excludes magic __get properties, so moodleform::set_data() + * cannot populate hidden fields (id, typeid, etc.) from a question object directly. + * This method materialises both the DB-backed magic fields and the declared public + * fields into a single flat stdClass that set_data() can work with. + * + * @return \stdClass + */ + public function form_data(): \stdClass { + $data = new \stdClass(); + // DB-backed fields — use named accessors. + $data->id = $this->id(); + $data->surveyid = $this->surveyid(); + $data->name = $this->name(); + $data->typeid = $this->typeid(); + $data->length = $this->length(); + $data->precise = $this->precise(); + $data->position = $this->position(); + $data->content = $this->content(); + $data->required = $this->record ? $this->record->get('required') : null; + $data->deleted = $this->deleted(); + $data->extradata = $this->extradata(); + // Declared public properties used by the edit-question form. + $data->qid = $this->qid; + $data->sid = $this->sid; + $data->choices = $this->choices; + $data->allchoices = $this->allchoices; + $data->dependquestion = $this->dependquestion; + $data->dependchoice = $this->dependchoice; + $data->dependquestionsand = $this->dependquestionsand; + $data->dependlogicand = $this->dependlogicand; + $data->dependquestionsor = $this->dependquestionsor; + $data->dependlogicor = $this->dependlogicor; + return $data; + } + /** * Short name for this question type - no spaces, etc.. * @return string @@ -208,14 +502,14 @@ abstract public function helpname(); * @return mixed */ public static function question_builder($qtype, $qdata = null, $context = null) { - $qclassname = '\\mod_questionnaire\\question\\' . self::qtypename($qtype); + $qclassname = '\\mod_questionnaire\\local\\question\\' . self::qtypename($qtype); $qid = 0; if (!empty($qdata) && is_array($qdata)) { $qdata = (object)$qdata; } else if (!empty($qdata) && is_int($qdata)) { $qid = $qdata; } - return new $qclassname($qid, $qdata, $context, ['type_id' => $qtype]); + return new $qclassname($qid, $qdata, $context, ['typeid' => $qtype]); } /** @@ -224,11 +518,7 @@ public static function question_builder($qtype, $qdata = null, $context = null) * @return string */ public static function qtypename($qtype) { - if (array_key_exists($qtype, self::$qtypenames)) { - return self::$qtypenames[$qtype]; - } else { - return(''); - } + return question_type::qtypename((int)$qtype); } /** @@ -236,7 +526,7 @@ public static function qtypename($qtype) { * @return array */ public static function qtypenames() { - return self::$qtypenames; + return question_type::qtypenames(); } /** @@ -247,16 +537,37 @@ public function has_choices() { return false; } + /** + * Return all question type IDs whose questions have choice records. + * + * Queries the questionnaire_question_type table and caches the result for the + * duration of the request. + * + * @return int[] + */ + public static function typeids_with_choices(): array { + static $cache = null; + if ($cache !== null) { + return $cache; + } + $records = question_type_record::get_records(['haschoices' => 'y']); + $cache = []; + foreach ($records as $record) { + $cache[] = (int)$record->get('typeid'); + } + return $cache; + } + /** * Load any choices into the object. * @throws \dml_exception */ private function get_choices() { - global $DB; - - if ($choices = $DB->get_records('questionnaire_quest_choice', ['question_id' => $this->id], 'id ASC')) { - foreach ($choices as $choice) { - $this->choices[$choice->id] = \mod_questionnaire\question\choice::create_from_data($choice); + $choicerecs = \mod_questionnaire\local\db\choice_record::get_for_question($this->id()); + if ($choicerecs) { + foreach ($choicerecs as $choicerec) { + $this->choices[$choicerec->get('id')] = + \mod_questionnaire\local\question\choice::create_from_data($choicerec->to_record()); } } else { $this->choices = []; @@ -268,7 +579,7 @@ private function get_choices() { * @return bool */ public function required() { - return ($this->required == 'y'); + return $this->record ? $this->record->get('required') == 'y' : false; } /** @@ -291,24 +602,74 @@ public function allows_dependents() { * Load any dependencies. */ private function get_dependencies() { - global $DB; - $this->dependencies = []; - if ( - $dependencies = $DB->get_records( - 'questionnaire_dependency', - ['questionid' => $this->id, 'surveyid' => $this->surveyid], - 'id ASC' - ) - ) { - foreach ($dependencies as $dependency) { - $this->dependencies[$dependency->id] = new \stdClass(); - $this->dependencies[$dependency->id]->dependquestionid = $dependency->dependquestionid; - $this->dependencies[$dependency->id]->dependchoiceid = $dependency->dependchoiceid; - $this->dependencies[$dependency->id]->dependlogic = $dependency->dependlogic; - $this->dependencies[$dependency->id]->dependandor = $dependency->dependandor; + $deprecs = \mod_questionnaire\local\db\dependency_record::get_records( + ['questionid' => $this->id(), 'surveyid' => $this->surveyid()] + ); + foreach ($deprecs as $deprec) { + $did = $deprec->get('id'); + $this->dependencies[$did] = new \stdClass(); + $this->dependencies[$did]->dependquestionid = $deprec->get('dependquestionid'); + $this->dependencies[$did]->dependchoiceid = $deprec->get('dependchoiceid'); + $this->dependencies[$did]->dependlogic = $deprec->get('dependlogic'); + $this->dependencies[$did]->dependandor = $deprec->get('dependandor'); + } + } + + /** + * Parse a choice content string and return its components. + * + * Examines the content of a possible answer from radio button, check box, or rate question + * and returns an object with the following properties: + * - text: displayable text component + * - image: full img tag if an image was embedded, or empty string + * - modname: named-modality prefix (before '::'), or empty string + * - title: image title/alt text, or empty string + * + * @param string $content + * @return stdClass + */ + public static function parse_choice_content(string $content): stdClass { + $contents = new stdClass(); + $contents->text = ''; + $contents->image = ''; + $contents->modname = ''; + $contents->title = ''; + // Has image. + if (preg_match('/(image = $matches[0]; + $imageurl = $matches[3]; + // Image has a title or alt text: use one of them. + if ( + preg_match('/(title=.)([^"]{1,})/', $content, $matches) || + preg_match('/(alt=.)([^"]{1,})/', $content, $matches) + ) { + $contents->title = $matches[2]; + } else { + // Image has no title nor alt text: use its filename (without the extension). + preg_match("/.*\/(.*)\..*$/", $imageurl, $matches); + $contents->title = $matches[1]; } + // Content has text or named modality plus an image. + if (preg_match('/(.*)(text = $content; + if ($pos = strpos($content, '::')) { + $contents->text = substr($content, $pos + 2); + $contents->modname = substr($content, 0, $pos); } + return $contents; } /** @@ -320,7 +681,7 @@ protected function get_dependency_options() { $options = []; if ($this->allows_dependents() && $this->has_choices()) { foreach ($this->choices as $key => $choice) { - $contents = questionnaire_choice_values($choice->content); + $contents = self::parse_choice_content($choice->content); if (!empty($contents->modname)) { $choice->content = $contents->modname; } else if (!empty($contents->title)) { // Must be an image; use its title for the dropdown list. @@ -328,7 +689,7 @@ protected function get_dependency_options() { } else { $choice->content = format_string($contents->text); } - $options[$this->id . ',' . $key] = $this->name . '->' . $choice->content; + $options[$this->id() . ',' . $key] = $this->name() . '->' . $choice->content; } } return $options; @@ -414,7 +775,7 @@ public function response_has_choice($rid, $choiceid) { $choiceval = $this->responsetype->transform_choiceid($choiceid); return $DB->record_exists( $this->response_table(), - ['response_id' => $rid, 'question_id' => $this->id, 'choice_id' => $choiceval] + ['responseid' => $rid, 'questionid' => $this->id(), 'choiceid' => $choiceval] ); } @@ -426,7 +787,7 @@ public function response_has_choice($rid, $choiceid) { public function insert_response($responsedata) { if ( isset($this->responsetype) && is_object($this->responsetype) && - is_subclass_of($this->responsetype, '\\mod_questionnaire\\responsetype\\responsetype') + is_subclass_of($this->responsetype, '\\mod_questionnaire\\local\\response\\responsetype') ) { return $this->responsetype->insert_response($responsedata); } else { @@ -442,7 +803,7 @@ public function insert_response($responsedata) { public function get_results($rids = false) { if ( isset($this->responsetype) && is_object($this->responsetype) && - is_subclass_of($this->responsetype, '\\mod_questionnaire\\responsetype\\responsetype') + is_subclass_of($this->responsetype, '\\mod_questionnaire\\local\\response\\responsetype') ) { return $this->responsetype->get_results($rids); } else { @@ -455,14 +816,15 @@ public function get_results($rids = false) { * @param bool $rids * @param string $sort * @param bool $anonymous + * @param int|null $currentgroupid Active group filter id for staff respondent-link URLs. * @return false|string */ - public function display_results($rids = false, $sort = '', $anonymous = false) { + public function display_results($rids = false, $sort = '', $anonymous = false, ?int $currentgroupid = null) { if ( isset($this->responsetype) && is_object($this->responsetype) && - is_subclass_of($this->responsetype, '\\mod_questionnaire\\responsetype\\responsetype') + is_subclass_of($this->responsetype, '\\mod_questionnaire\\local\\response\\responsetype') ) { - return $this->responsetype->display_results($rids, $sort, $anonymous); + return $this->responsetype->display_results($rids, $sort, $anonymous, $currentgroupid); } else { return false; } @@ -549,7 +911,7 @@ public function is_numbered() { * @return bool */ public function valid_feedback() { - if ($this->supports_feedback() && $this->has_choices() && $this->required() && !empty($this->name)) { + if ($this->supports_feedback() && $this->has_choices() && $this->required() && !empty($this->name())) { foreach ($this->choices as $choice) { if ($choice->value != null) { return true; @@ -567,7 +929,7 @@ public function valid_feedback() { public function get_feedback_scores(array $rids) { if ( $this->valid_feedback() && isset($this->responsetype) && is_object($this->responsetype) && - is_subclass_of($this->responsetype, '\\mod_questionnaire\\responsetype\\responsetype') + is_subclass_of($this->responsetype, '\\mod_questionnaire\\local\\response\\responsetype') ) { return $this->responsetype->get_feedback_scores($rids); } else { @@ -601,10 +963,10 @@ public function get_feedback_maxscore() { * @return bool */ public function response_complete($responsedata) { - if (is_a($responsedata, 'mod_questionnaire\responsetype\response\response')) { + if (is_a($responsedata, 'mod_questionnaire\local\response\response')) { // If $responsedata is a response object, look through the answers. - if (isset($responsedata->answers[$this->id]) && !empty($responsedata->answers[$this->id])) { - $answer = $responsedata->answers[$this->id][0]; + if (isset($responsedata->answers[$this->id()]) && !empty($responsedata->answers[$this->id()])) { + $answer = $responsedata->answers[$this->id()][0]; if ( !empty($answer->choiceid) && isset($this->choices[$answer->choiceid]) && $this->choices[$answer->choiceid]->is_other_choice() @@ -618,9 +980,9 @@ public function response_complete($responsedata) { } } else { // If $responsedata is webform data, check that its not empty. - $answered = isset($responsedata->{'q' . $this->id}) && ($responsedata->{'q' . $this->id} != ''); + $answered = isset($responsedata->{'q' . $this->id()}) && ($responsedata->{'q' . $this->id()} != ''); } - return !($this->required() && (empty($this->deleted)) && !$answered); + return !($this->required() && (empty($this->deleted())) && !$answered); } /** @@ -638,33 +1000,10 @@ public function response_valid($responsedata) { * @param bool $updatechoices True if choices should also be updated. */ public function update($questionrecord = null, $updatechoices = true) { - global $DB; - - if ($questionrecord === null) { - $questionrecord = new \stdClass(); - $questionrecord->id = $this->id; - $questionrecord->surveyid = $this->surveyid; - $questionrecord->name = $this->name; - $questionrecord->type_id = $this->type_id; - $questionrecord->result_id = $this->result_id; - $questionrecord->length = $this->length; - $questionrecord->precise = $this->precise; - $questionrecord->position = $this->position; - $questionrecord->content = $this->content; - $questionrecord->required = $this->required; - $questionrecord->deleted = $this->deleted; - $questionrecord->extradata = $this->extradata; - $questionrecord->dependquestion = $this->dependquestion; - $questionrecord->dependchoice = $this->dependchoice; - } else { - // Make sure the "id" field is this question's. - if (isset($this->qid) && ($this->qid > 0)) { - $questionrecord->id = $this->qid; - } else { - $questionrecord->id = $this->id; - } + if ($questionrecord !== null) { + $this->record->from_record($questionrecord); } - $DB->update_record('questionnaire_question', $questionrecord); + $this->record->update(); if ($updatechoices && $this->has_choices()) { $this->update_choices(); @@ -678,32 +1017,29 @@ public function update($questionrecord = null, $updatechoices = true) { * @param bool|null $calcposition Whether or not to calculate the next available position in the survey. */ public function add($questionrecord, ?array $choicerecords = null, ?bool $calcposition = true) { - global $DB; - // Create new question. if ($calcposition) { // Set the position to the end. - $sql = 'SELECT MAX(position) as maxpos ' . - 'FROM {questionnaire_question} ' . - 'WHERE surveyid = ? AND deleted IS NULL'; - $params = ['surveyid' => $questionrecord->surveyid]; - if ($record = $DB->get_record_sql($sql, $params)) { - $questionrecord->position = $record->maxpos + 1; - } else { - $questionrecord->position = 1; - } + $questionrecord->position = \mod_questionnaire\local\db\question_record::max_active_position_for_survey( + (int) $questionrecord->surveyid + ) + 1; } // Make sure we add all necessary data. - if (!isset($questionrecord->type_id) || empty($questionrecord->type_id)) { - $questionrecord->type_id = $this->type_id; + if (!isset($questionrecord->typeid) || empty($questionrecord->typeid)) { + $questionrecord->typeid = $this->typeid(); } - $this->qid = $DB->insert_record('questionnaire_question', $questionrecord); + $questionpersistent = new \mod_questionnaire\local\db\question_record(0, $questionrecord); + $questionpersistent->save(); + // Adopt the freshly-saved persistent as this question's backing record so + // subsequent mutations and accessors are in sync without an extra DB read. + $this->record = $questionpersistent; + $this->qid = $questionpersistent->get('id'); if ($this->has_choices() && !empty($choicerecords)) { foreach ($choicerecords as $choicerecord) { - $choicerecord->question_id = $this->qid; + $choicerecord->questionid = $this->qid; $this->add_choice($choicerecord); } } @@ -720,12 +1056,12 @@ public function update_choices() { if (isset($this->qid) && ($this->qid > 0)) { $qid = $this->qid; } else { - $qid = $this->id; + $qid = $this->id(); } foreach ($this->choices as $key => $choice) { $choicerecord = new \stdClass(); $choicerecord->id = $key; - $choicerecord->question_id = $qid; + $choicerecord->questionid = $qid; $choicerecord->content = $choice->content; $choicerecord->value = $choice->value; $retvalue &= $this->update_choice($choicerecord); @@ -740,8 +1076,15 @@ public function update_choices() { * @return bool */ public function update_choice($choicerecord) { - global $DB; - return $DB->update_record('questionnaire_quest_choice', $choicerecord); + $choicepersistent = \mod_questionnaire\local\db\choice_record::get_record(['id' => $choicerecord->id]); + if (!$choicepersistent) { + return false; + } + $choicepersistent->set('questionid', $choicerecord->questionid); + $choicepersistent->set('content', $choicerecord->content); + $choicepersistent->set('value', $choicerecord->value ?? null); + $choicepersistent->save(); + return true; } /** @@ -750,16 +1093,16 @@ public function update_choice($choicerecord) { * @return bool */ public function add_choice($choicerecord) { - global $DB; - $retvalue = true; - if ($cid = $DB->insert_record('questionnaire_quest_choice', $choicerecord)) { + $choicepersistent = new \mod_questionnaire\local\db\choice_record(0, $choicerecord); + $choicepersistent->save(); + $cid = $choicepersistent->get('id'); + if ($cid) { $this->choices[$cid] = new \stdClass(); $this->choices[$cid]->content = $choicerecord->content; - $this->choices[$cid]->value = isset($choicerecord->value) ? $choicerecord->value : null; - } else { - $retvalue = false; + $this->choices[$cid]->value = $choicerecord->value ?? null; + return true; } - return $retvalue; + return false; } /** @@ -773,7 +1116,7 @@ public function delete_choice($choice) { } else { $cid = $choice->id; } - if (\mod_questionnaire\question\choice::delete_from_db_by_id($cid)) { + if (\mod_questionnaire\local\question\choice::delete_from_db_by_id($cid)) { unset($this->choices[$cid]); } else { $retvalue = false; @@ -787,8 +1130,8 @@ public function delete_choice($choice) { * @return bool */ public function insert_extradata($extradata) { - global $DB; - return $DB->set_field('questionnaire_question', 'extradata', $extradata, ['id' => $this->id]); + $this->record->set('extradata', $extradata); + return $this->record->update(); } /** @@ -797,8 +1140,18 @@ public function insert_extradata($extradata) { * @return bool */ public function update_dependency($dependencyrecord) { - global $DB; - return $DB->update_record('questionnaire_dependency', $dependencyrecord); + $deppersistent = \mod_questionnaire\local\db\dependency_record::get_record(['id' => $dependencyrecord->id]); + if (!$deppersistent) { + return false; + } + $deppersistent->set('questionid', $dependencyrecord->questionid); + $deppersistent->set('surveyid', $dependencyrecord->surveyid); + $deppersistent->set('dependquestionid', $dependencyrecord->dependquestionid); + $deppersistent->set('dependchoiceid', $dependencyrecord->dependchoiceid); + $deppersistent->set('dependlogic', $dependencyrecord->dependlogic); + $deppersistent->set('dependandor', $dependencyrecord->dependandor); + $deppersistent->save(); + return true; } /** @@ -807,19 +1160,18 @@ public function update_dependency($dependencyrecord) { * @return bool */ public function add_dependency($dependencyrecord) { - global $DB; - - $retvalue = true; - if ($did = $DB->insert_record('questionnaire_dependency', $dependencyrecord)) { + $deppersistent = new \mod_questionnaire\local\db\dependency_record(0, $dependencyrecord); + $deppersistent->save(); + $did = $deppersistent->get('id'); + if ($did) { $this->dependencies[$did] = new \stdClass(); $this->dependencies[$did]->dependquestionid = $dependencyrecord->dependquestionid; $this->dependencies[$did]->dependchoiceid = $dependencyrecord->dependchoiceid; $this->dependencies[$did]->dependlogic = $dependencyrecord->dependlogic; $this->dependencies[$did]->dependandor = $dependencyrecord->dependandor; - } else { - $retvalue = false; + return true; } - return $retvalue; + return false; } /** @@ -827,20 +1179,18 @@ public function add_dependency($dependencyrecord) { * @param int|\stdClass $dependency Either the integer id of the dependency, or the dependency record. */ public function delete_dependency($dependency) { - global $DB; - - $retvalue = true; if (is_int($dependency)) { $did = $dependency; } else { $did = $dependency->id; } - if ($DB->delete_records('questionnaire_dependency', ['id' => $did])) { + $deppersistent = \mod_questionnaire\local\db\dependency_record::get_record(['id' => $did]); + if ($deppersistent) { + $deppersistent->delete(); unset($this->dependencies[$did]); - } else { - $retvalue = false; + return true; } - return $retvalue; + return false; } /** @@ -848,16 +1198,9 @@ public function delete_dependency($dependency) { * @param bool $required Whether question should be required or not. */ public function set_required($required) { - global $DB; $rval = $required ? 'y' : 'n'; - // Need to fix this messed-up qid/id issue. - if (isset($this->qid) && ($this->qid > 0)) { - $qid = $this->qid; - } else { - $qid = $this->id; - } - $this->required = $rval; - return $DB->set_field('questionnaire_question', 'required', $rval, ['id' => $qid]); + $this->record->set('required', $rval); + return $this->record->update(); } /** @@ -897,10 +1240,10 @@ public function response_template() { * @param bool $pdf * @return bool|string */ - public function results_template($pdf = false) { + public function results_template(bool $pdf = false) { if ( isset($this->responsetype) && is_object($this->responsetype) && - is_subclass_of($this->responsetype, '\\mod_questionnaire\\responsetype\\responsetype') + is_subclass_of($this->responsetype, '\\mod_questionnaire\\local\\response\\responsetype') ) { return $this->responsetype->results_template($pdf); } else { @@ -910,26 +1253,39 @@ public function results_template($pdf = false) { /** * Get the output for question renderers / templates. - * @param \mod_questionnaire\responsetype\response\response $response - * @param boolean $blankquestionnaire + * + * @param \mod_questionnaire\local\response\response|\stdClass $response The response object or form data. + * @param bool $blankquestionnaire Whether the questionnaire is blank. * @param array $dependants Array of all questions/choices depending on this question. - * @param int $qnum - * @return \stdClass - */ - public function question_output($response, $blankquestionnaire, $dependants = [], $qnum = '') { - $pagetags = $this->questionstart_survey_display($qnum, $response); + * @param int|string $qnum The question number. + * @param \questionnaire|null $questionnaire The parent questionnaire object. + * @param bool $individualresponse True when rendering a single individual response (mybyresponse / individualresp). + * @return \stdClass The output object for rendering. + */ + public function question_output( + $response, + $blankquestionnaire, + $dependants = [], + $qnum = '', + $questionnaire = null, + bool $individualresponse = false + ): stdClass { + $this->questionnaire = $questionnaire; + $pagetags = $this->questionstart_survey_display($qnum, $response, $questionnaire, $individualresponse); $pagetags->qformelement = $this->question_survey_display($response, $dependants, $blankquestionnaire); return $pagetags; } /** * Get the output for question renderers / templates. - * @param \mod_questionnaire\responsetype\response\response $response + * @param \mod_questionnaire\local\response\response $response * @param string $qnum + * @param \questionnaire|null $questionnaire The parent questionnaire object. + * @param bool $individualresponse True when rendering a single individual response (mybyresponse / individualresp). * @return \stdClass */ - public function response_output($response, $qnum = '') { - $pagetags = $this->questionstart_survey_display($qnum, $response); + public function response_output($response, $qnum = '', $questionnaire = null, bool $individualresponse = false) { + $pagetags = $this->questionstart_survey_display($qnum, $response, $questionnaire, $individualresponse); $pagetags->qformelement = $this->response_survey_display($response); return $pagetags; } @@ -937,28 +1293,34 @@ public function response_output($response, $qnum = '') { /** * Get the output for the start of the questions in a survey. * @param int $qnum - * @param \mod_questionnaire\responsetype\response\response $response + * @param response $response + * @param questionnaire|null $questionnaire The parent questionnaire object. + * @param bool $individualresponse True when rendering a single individual response (mybyresponse / individualresp). * @return \stdClass */ - public function questionstart_survey_display($qnum, $response = null) { - global $OUTPUT, $SESSION, $questionnaire, $PAGE; + public function questionstart_survey_display( + $qnum, + $response = null, + $questionnaire = null, + bool $individualresponse = false + ): stdClass { + global $OUTPUT, $PAGE; $pagetags = new \stdClass(); - $currenttab = $SESSION->questionnaire->current_tab; $pagetype = $PAGE->pagetype; $skippedclass = ''; // If no questions autonumbering. $nonumbering = false; - if (!$questionnaire->questions_autonumbered()) { + if ($questionnaire !== null && !$questionnaire->questions_autonumbered()) { $qnum = ''; $nonumbering = true; } // For now, check what the response type is until we've got it all refactored. - if ($response instanceof \mod_questionnaire\responsetype\response\response) { - $skippedquestion = !isset($response->answers[$this->id]); + if ($response instanceof \mod_questionnaire\local\response\response) { + $skippedquestion = !isset($response->answers[$this->id()]); } else { - $skippedquestion = !empty($response) && !isset($response->{'q' . $this->id}); + $skippedquestion = !empty($response) && !isset($response->{'q' . $this->id()}); } // If we are on report page and this questionnaire has dependquestions and this question was skipped. @@ -974,20 +1336,23 @@ public function questionstart_survey_display($qnum, $response = null) { // also hide answers to questions that have not been answered. $displayclass = 'qn-container'; if ( - $pagetype == 'mod-questionnaire-preview' || - ($nonumbering && ($currenttab == 'mybyresponse' || $currenttab == 'individualresp')) + $questionnaire !== null && + ( + $pagetype == 'mod-questionnaire-preview' || + ($nonumbering && $individualresponse) + ) ) { // This needs to be done to ensure all dependency data is loaded. // TODO - Perhaps this should be a function called by the questionnaire after it loads all questions? $questionnaire->load_parents($this); - // Want this to come from the renderer, meaning we need $questionnaire. - $pagetags->dependencylist = $questionnaire->renderer->get_dependency_html($this->id, $this->dependencies); + $pagetags->dependencylist = $PAGE->get_renderer('mod_questionnaire') + ->get_dependency_html($this->id(), $this->dependencies); } - $pagetags->fieldset = (object)['id' => $this->id, 'class' => $displayclass]; + $pagetags->fieldset = (object)['id' => $this->id(), 'class' => $displayclass]; // Do not display the info box for the label question type. - if ($this->type_id != QUESSECTIONTEXT) { + if ($this->typeid() != QUESSECTIONTEXT) { if (!$nonumbering) { $pagetags->qnum = $qnum; } @@ -1002,24 +1367,24 @@ public function questionstart_survey_display($qnum, $response = null) { $pagetags->required = $required; // Need to replace this with better renderer / template? } // If question text is "empty", i.e. 2 non-breaking spaces were inserted, empty it. - if ($this->content == '

  

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

  

') { + $this->set_content(''); } $pagetags->skippedclass = $skippedclass; - if ($this->type_id == QUESNUMERIC || $this->type_id == QUESTEXT || $this->type_id == QUESSLIDER) { - $pagetags->label = (object)['for' => self::qtypename($this->type_id) . $this->id]; - } else if ($this->type_id == QUESDROP) { - $pagetags->label = (object)['for' => self::qtypename($this->type_id) . $this->name]; - } else if ($this->type_id == QUESESSAY) { - $pagetags->label = (object)['for' => 'q' . $this->id]; + if ($this->typeid() == QUESNUMERIC || $this->typeid() == QUESTEXT || $this->typeid() == QUESSLIDER) { + $pagetags->label = (object)['for' => self::qtypename($this->typeid()) . $this->id()]; + } else if ($this->typeid() == QUESDROP) { + $pagetags->label = (object)['for' => self::qtypename($this->typeid()) . $this->name()]; + } else if ($this->typeid() == QUESESSAY) { + $pagetags->label = (object)['for' => 'q' . $this->id()]; } $content = file_rewrite_pluginfile_urls( - $this->content, + $this->content(), 'pluginfile.php', $this->context->id, 'mod_questionnaire', 'question', - $this->id + $this->id() ); $options = ['noclean' => true, 'para' => false, 'filter' => true, 'context' => $this->context, 'overflowdiv' => true]; $pagetags->qcontent = format_text($content, FORMAT_HTML, $options); @@ -1038,10 +1403,10 @@ public function questionstart_survey_display($qnum, $response = null) { * Override this, or any of the internal methods, to provide specific form data for editing the question type. * The structure of the elements here is the default layout for the question form. * @param edit_question_form $form The main moodleform object. - * @param questionnaire $questionnaire The questionnaire being edited. + * @param \mod_questionnaire\questionnaire $questionnaire The questionnaire being edited. * @return bool */ - public function edit_form(edit_question_form $form, questionnaire $questionnaire) { + public function edit_form(edit_question_form $form, \mod_questionnaire\questionnaire $questionnaire) { $mform =& $form->_form; $this->form_header($mform); $this->form_name($mform); @@ -1058,8 +1423,8 @@ public function edit_form(edit_question_form $form, questionnaire $questionnaire $this->form_extradata($mform); // Added for advanced dependencies, parameter $editformobject is needed to use repeat_elements. - if ($questionnaire->navigate > 0) { - $this->form_dependencies($form, $questionnaire->questions); + if ($questionnaire->navigate() > 0) { + $this->form_dependencies($form, $questionnaire->questions()); } // Exclude the save/cancel buttons from any collapsing sections. @@ -1072,8 +1437,8 @@ public function edit_form(edit_question_form $form, questionnaire $questionnaire $mform->setType('qid', PARAM_INT); $mform->addElement('hidden', 'sid', 0); $mform->setType('sid', PARAM_INT); - $mform->addElement('hidden', 'type_id', $this->type_id); - $mform->setType('type_id', PARAM_INT); + $mform->addElement('hidden', 'typeid', $this->typeid()); + $mform->setType('typeid', PARAM_INT); $mform->addElement('hidden', 'action', 'question'); $mform->setType('action', PARAM_ALPHA); @@ -1096,9 +1461,9 @@ public function edit_form(edit_question_form $form, questionnaire $questionnaire protected function form_header(\MoodleQuickForm $mform, $helpname = '') { // Display different messages for new question creation and existing question modification. if (isset($this->qid) && !empty($this->qid)) { - $header = get_string('editquestion', 'questionnaire', questionnaire_get_type($this->type_id)); + $header = get_string('editquestion', 'questionnaire', question_type::display_name($this->typeid())); } else { - $header = get_string('addnewquestion', 'questionnaire', questionnaire_get_type($this->type_id)); + $header = get_string('addnewquestion', 'questionnaire', question_type::display_name($this->typeid())); } if (empty($helpname)) { $helpname = $this->helpname(); @@ -1166,15 +1531,15 @@ protected function form_precise(\MoodleQuickForm $mform, $helpname = '') { protected function form_dependencies($form, $questions) { // Create a new area for multiple dependencies. $mform = $form->_form; - $position = ($this->position !== 0) ? $this->position : count($questions) + 1; + $position = ($this->position() !== 0) ? $this->position() : count($questions) + 1; $dependencies = []; $dependencies[''][0] = get_string('choosedots'); foreach ($questions as $question) { if ( - ($question->position < $position) && !empty($question->name) && + ($question->position() < $position) && !empty($question->name()) && !empty($dependopts = $question->get_dependency_options()) ) { - $dependencies[$question->name] = $dependopts; + $dependencies[$question->name()] = $dependopts; } } @@ -1425,11 +1790,9 @@ public static function form_precise_text(\MoodleQuickForm $mform, $helpname = '' /** * Create and update question data from the forms. * @param \stdClass $formdata - * @param questionnaire $questionnaire + * @param \mod_questionnaire\questionnaire $questionnaire */ public function form_update($formdata, $questionnaire) { - global $DB; - $this->form_preprocess_data($formdata); if (!empty($formdata->qid)) { // Update existing question. @@ -1439,7 +1802,7 @@ public function form_update($formdata, $questionnaire) { $formdata->content = $formdata->content['text']; $formdata->content = file_save_draft_area_files( $formdata->itemid, - $questionnaire->context->id, + $questionnaire->context()->id, 'mod_questionnaire', 'question', $formdata->qid, @@ -1447,7 +1810,7 @@ public function form_update($formdata, $questionnaire) { $formdata->content ); - $fields = ['name', 'type_id', 'length', 'precise', 'required', 'content', 'extradata']; + $fields = ['name', 'typeid', 'length', 'precise', 'required', 'content', 'extradata']; $questionrecord = new \stdClass(); $questionrecord->id = $formdata->qid; foreach ($fields as $f) { @@ -1459,13 +1822,13 @@ public function form_update($formdata, $questionnaire) { $this->update($questionrecord, false); if ($questionnaire->has_dependencies()) { - questionnaire_check_page_breaks($questionnaire); + $questionnaire->survey()->check_page_breaks(); } } else { // Create new question: // Need to update any image content after the question is created, so create then update the content. $formdata->surveyid = $formdata->sid; - $fields = ['surveyid', 'name', 'type_id', 'length', 'precise', 'required', 'position', 'extradata']; + $fields = ['surveyid', 'name', 'typeid', 'length', 'precise', 'required', 'position', 'extradata']; $questionrecord = new \stdClass(); foreach ($fields as $f) { if (isset($formdata->$f)) { @@ -1482,14 +1845,15 @@ public function form_update($formdata, $questionnaire) { $formdata->content = $formdata->content['text']; $content = file_save_draft_area_files( $formdata->itemid, - $questionnaire->context->id, + $questionnaire->context()->id, 'mod_questionnaire', 'question', $this->qid, ['subdirs' => true], $formdata->content ); - $DB->set_field('questionnaire_question', 'content', $content, ['id' => $this->qid]); + $this->record->set('content', $content); + $this->record->update(); } if ($this->has_choices()) { @@ -1511,7 +1875,7 @@ public function form_update($formdata, $questionnaire) { if ($newchoices[$nidx] != $echoice->content) { $choicerecord = new \stdClass(); $choicerecord->id = $ekey; - $choicerecord->question_id = $this->qid; + $choicerecord->questionid = $this->qid; $choicerecord->content = trim($newchoices[$nidx]); $r = preg_match_all("/^(\d{1,2})(=.*)$/", $newchoices[$nidx], $matches); // This choice has been attributed a "score value" OR this is a rate question type. @@ -1532,7 +1896,7 @@ public function form_update($formdata, $questionnaire) { while ($nidx < $newcount) { // New choices. $choicerecord = new \stdClass(); - $choicerecord->question_id = $this->qid; + $choicerecord->questionid = $this->qid; $choicerecord->content = trim($newchoices[$nidx]); $r = preg_match_all("/^(\d{1,2})(=.*)$/", $choicerecord->content, $matches); // This choice has been attributed a "score value" OR this is a rate question type. @@ -1582,7 +1946,7 @@ public function form_update($formdata, $questionnaire) { $dependencyrecord = new \stdClass(); $dependencyrecord->id = $ekey; $dependencyrecord->questionid = $this->qid; - $dependencyrecord->surveyid = $this->surveyid; + $dependencyrecord->surveyid = $this->surveyid(); $dependencyrecord->dependquestionid = $formdata->dependquestion[$nidx]; $dependencyrecord->dependchoiceid = $formdata->dependchoice[$nidx]; $dependencyrecord->dependlogic = $formdata->dependlogic_cleaned[$nidx]; @@ -1699,35 +2063,35 @@ public function mobile_question_display($qnum, $autonum = false) { $options = ['noclean' => true, 'para' => false, 'filter' => true, 'context' => $this->context, 'overflowdiv' => true]; $mobiledata = (object)[ - 'id' => $this->id, - 'name' => $this->name, - 'type_id' => $this->type_id, - 'length' => $this->length, + 'id' => $this->id(), + 'name' => $this->name(), + 'typeid' => $this->typeid(), + 'length' => $this->length(), 'content' => format_text( file_rewrite_pluginfile_urls( - $this->content, + $this->content(), 'pluginfile.php', $this->context->id, 'mod_questionnaire', 'question', - $this->id + $this->id() ), FORMAT_HTML, $options ), - 'content_stripped' => strip_tags($this->content), - 'required' => ($this->required == 'y') ? 1 : 0, - 'deleted' => $this->deleted, - 'response_table' => $this->responsetable, + 'content_stripped' => strip_tags($this->content()), + 'required' => $this->required() ? 1 : 0, + 'deleted' => $this->deleted(), + 'response_table' => $this->questiontype ? $this->questiontype->responsetable : null, 'fieldkey' => $this->mobile_fieldkey(), - 'precise' => $this->precise, + 'precise' => $this->precise(), 'qnum' => $qnum, - 'errormessage' => get_string('required') . ': ' . $this->name, + 'errormessage' => get_string('required') . ': ' . $this->name(), ]; $mobiledata->choices = $this->mobile_question_choices_display(); if ($this->mobile_question_extradata_display()) { - $mobiledata->extradata = json_decode($this->extradata); + $mobiledata->extradata = json_decode($this->extradata()); } if ($autonum) { $mobiledata->content = $qnum . '. ' . $mobiledata->content; @@ -1747,7 +2111,7 @@ public function mobile_question_choices_display() { if ($this->has_choices()) { foreach ($this->choices as $choice) { $choices[$cnum] = clone($choice); - $contents = questionnaire_choice_values($choice->content); + $contents = self::parse_choice_content($choice->content); $choices[$cnum]->content = format_text($contents->text, FORMAT_HTML, ['noclean' => true]) . $contents->image; $cnum++; } @@ -1765,7 +2129,7 @@ public function mobile_fieldkey($choiceid = 0) { if ($choiceid !== 0) { $choicefield = '_' . $choiceid; } - return 'response_' . $this->type_id . '_' . $this->id . $choicefield; + return 'response_' . $this->typeid() . '_' . $this->id() . $choicefield; } /** @@ -1775,8 +2139,8 @@ public function mobile_fieldkey($choiceid = 0) { */ public function get_mobile_response_data($response) { $resultdata = []; - if (isset($response->answers[$this->id][0])) { - $resultdata[$this->mobile_fieldkey()] = $response->answers[$this->id][0]->value; + if (isset($response->answers[$this->id()][0])) { + $resultdata[$this->mobile_fieldkey()] = $response->answers[$this->id()][0]->value; } else { $resultdata[$this->mobile_fieldkey()] = false; } diff --git a/classes/question/radio.php b/classes/local/question/radio.php similarity index 88% rename from classes/question/radio.php rename to classes/local/question/radio.php index ea86e0e0a..3a3e37d50 100644 --- a/classes/question/radio.php +++ b/classes/local/question/radio.php @@ -14,7 +14,7 @@ // You should have received a copy of the GNU General Public License // along with Moodle. If not, see . -namespace mod_questionnaire\question; +namespace mod_questionnaire\local\question; /** * This file contains the parent class for radio question types. @@ -30,7 +30,7 @@ class radio extends question { * @return object The response object based off of questionnaire_response_base. */ protected function responseclass() { - return '\\mod_questionnaire\\responsetype\\single'; + return '\\mod_questionnaire\\local\\response\\single'; } /** @@ -81,7 +81,7 @@ public function supports_feedback() { /** * Return the context tags for the check question template. - * @param \mod_questionnaire\responsetype\response\response $response + * @param \mod_questionnaire\local\response\response $response * @param array $dependants Array of all questions/choices depending on this question. * @param boolean $blankquestionnaire * @return object The check question context tags. @@ -92,7 +92,7 @@ protected function question_survey_display($response, $dependants = [], $blankqu global $idcounter; // To make sure all radio buttons have unique ids. // JR 20 NOV 2007. $otherempty = false; - $horizontal = $this->length; + $horizontal = $this->length(); $ischecked = false; $choicetags = new \stdClass(); @@ -107,10 +107,10 @@ protected function question_survey_display($response, $dependants = [], $blankqu if (!$choice->is_other_choice()) { // This is a normal radio button. $htmlid = 'auto-rb' . sprintf('%04d', ++$idcounter); - $radio->name = 'q' . $this->id; + $radio->name = 'q' . $this->id(); $radio->id = $htmlid; $radio->value = $id; - if (isset($response->answers[$this->id][$id])) { + if (isset($response->answers[$this->id()][$id])) { $radio->checked = true; $ischecked = true; } @@ -119,7 +119,7 @@ protected function question_survey_display($response, $dependants = [], $blankqu $radio->disabled = true; $value = ' (' . $choice->value . ') '; } - $contents = questionnaire_choice_values($choice->content); + $contents = question::parse_choice_content($choice->content); $radio->label = $value . format_text($contents->text, FORMAT_HTML, ['noclean' => true]) . $contents->image; if (!empty($this->qlegend)) { $radio->alabel = strip_tags("{$this->qlegend} {$radio->label}"); @@ -127,19 +127,19 @@ protected function question_survey_display($response, $dependants = [], $blankqu } else { // Radio button with associated !other text field. $othertext = $choice->other_choice_display(); $cname = choice::id_other_choice_name($id); - $odata = isset($response->answers[$this->id][$id]) ? $response->answers[$this->id][$id]->value : ''; + $odata = isset($response->answers[$this->id()][$id]) ? $response->answers[$this->id()][$id]->value : ''; $htmlid = 'auto-rb' . sprintf('%04d', ++$idcounter); - $radio->name = 'q' . $this->id; + $radio->name = 'q' . $this->id(); $radio->id = $htmlid; $radio->value = $id; - if (isset($response->answers[$this->id][$id]) || !empty($odata)) { + if (isset($response->answers[$this->id()][$id]) || !empty($odata)) { $radio->checked = true; $ischecked = true; } $otherempty = !empty($radio->checked) && empty($odata); $radio->label = format_text($othertext, FORMAT_HTML, ['noclean' => true]); - $radio->oname = 'q' . $this->id . choice::id_other_choice_name($id); + $radio->oname = 'q' . $this->id() . choice::id_other_choice_name($id); $radio->oid = $htmlid . '-other'; if (isset($odata)) { $radio->ovalue = format_string(stripslashes($odata)); @@ -161,7 +161,7 @@ protected function question_survey_display($response, $dependants = [], $blankqu $radio->horizontal = $horizontal; } - $radio->name = 'q' . $this->id; + $radio->name = 'q' . $this->id(); $radio->id = $htmlid; $radio->value = 0; @@ -186,7 +186,7 @@ protected function question_survey_display($response, $dependants = [], $blankqu /** * Return the context tags for the radio response template. - * @param \mod_questionnaire\responsetype\response\response $response + * @param \mod_questionnaire\local\response\response $response * @return object The radio question response context tags. */ protected function response_survey_display($response) { @@ -196,9 +196,9 @@ protected function response_survey_display($response) { $resptags->choices = []; $qdata = new \stdClass(); - $horizontal = $this->length; - if (isset($response->answers[$this->id])) { - $answer = reset($response->answers[$this->id]); + $horizontal = $this->length(); + if (isset($response->answers[$this->id()])) { + $answer = reset($response->answers[$this->id()]); $checked = $answer->choiceid; } else { $checked = null; @@ -209,7 +209,7 @@ protected function response_survey_display($response) { $chobj->horizontal = 1; } $chobj->name = $id . $uniquetag++; - $contents = questionnaire_choice_values($choice->content); + $contents = question::parse_choice_content($choice->content); $choice->content = $contents->text . $contents->image; if ($id == $checked) { $chobj->selected = 1; @@ -239,12 +239,12 @@ protected function response_survey_display($response) { */ public function response_complete($responsedata) { if ( - isset($responsedata->{'q' . $this->id}) && ($this->required()) && - (strpos($responsedata->{'q' . $this->id}, 'other_') !== false) + isset($responsedata->{'q' . $this->id()}) && ($this->required()) && + (strpos($responsedata->{'q' . $this->id()}, 'other_') !== false) ) { return ( trim( - $responsedata->{'q' . $this->id . '' . substr($responsedata->{'q' . $this->id}, 5)} + $responsedata->{'q' . $this->id() . '' . substr($responsedata->{'q' . $this->id()}, 5)} ) != false ); } else { @@ -320,8 +320,8 @@ public function mobile_question_choices_display() { */ public function get_mobile_response_data($response) { $resultdata = []; - if (isset($response->answers[$this->id])) { - foreach ($response->answers[$this->id] as $answer) { + if (isset($response->answers[$this->id()])) { + foreach ($response->answers[$this->id()] as $answer) { // Add a fieldkey for each choice. $resultdata[$this->mobile_fieldkey()] = $answer->choiceid; if ($this->choices[$answer->choiceid]->is_other_choice()) { diff --git a/classes/question/rate.php b/classes/local/question/rate.php similarity index 83% rename from classes/question/rate.php rename to classes/local/question/rate.php index d9ab3ced6..d4dac4325 100644 --- a/classes/question/rate.php +++ b/classes/local/question/rate.php @@ -14,7 +14,10 @@ // You should have received a copy of the GNU General Public License // along with Moodle. If not, see . -namespace mod_questionnaire\question; +namespace mod_questionnaire\local\question; + +use mod_questionnaire\local\db\question_record; +use mod_questionnaire\local\db\response_rank_record; /** * This file contains the parent class for rate question types. @@ -42,7 +45,7 @@ class rate extends question { * @param array $params */ public function __construct($id = 0, $question = null, $context = null, $params = []) { - $this->length = 5; + $this->set_length(5); parent::__construct($id, $question, $context, $params); $this->add_nameddegrees_from_extradata(); } @@ -52,7 +55,7 @@ public function __construct($id = 0, $question = null, $context = null, $params * @return object The response object based off of questionnaire_response_base. */ protected function responseclass() { - return '\\mod_questionnaire\\responsetype\\rank'; + return '\\mod_questionnaire\\local\\response\\rank'; } /** @@ -127,7 +130,7 @@ public static function type_is_osgood_rate_scale($scaletype) { * @return bool */ public function normal_rate_scale() { - return self::type_is_normal_rate_scale($this->precise); + return self::type_is_normal_rate_scale($this->precise()); } /** @@ -135,7 +138,7 @@ public function normal_rate_scale() { * @return bool */ public function has_na_column() { - return self::type_is_na_column($this->precise); + return self::type_is_na_column($this->precise()); } /** @@ -143,7 +146,7 @@ public function has_na_column() { * @return bool */ public function no_duplicate_choices() { - return self::type_is_no_duplicate_choices($this->precise); + return self::type_is_no_duplicate_choices($this->precise()); } /** @@ -151,7 +154,7 @@ public function no_duplicate_choices() { * @return bool */ public function osgood_rate_scale() { - return self::type_is_osgood_rate_scale($this->precise); + return self::type_is_osgood_rate_scale($this->precise()); } /** @@ -165,7 +168,7 @@ public function supports_feedback() { * True if the question supports feedback and has valid settings for feedback. Override if the default logic is not enough. */ public function valid_feedback() { - return $this->supports_feedback() && $this->has_choices() && $this->required() && !empty($this->name) && + return $this->supports_feedback() && $this->has_choices() && $this->required() && !empty($this->name()) && ($this->normal_rate_scale() || $this->osgood_rate_scale()) && !empty($this->nameddegrees); } @@ -192,7 +195,7 @@ public function get_feedback_maxscore() { /** * Return the context tags for the check question template. - * @param \mod_questionnaire\responsetype\response\response $response + * @param \mod_questionnaire\local\response\response $response * @param string $descendantsdata * @param boolean $blankquestionnaire * @return object The check question context tags. @@ -203,14 +206,14 @@ public function get_feedback_maxscore() { protected function question_survey_display($response, $descendantsdata, $blankquestionnaire = false) { $choicetags = new \stdClass(); $choicetags->qelements = []; - $choicetags->qelements['caption'] = strip_tags($this->content); + $choicetags->qelements['caption'] = strip_tags($this->content()); $disabled = ''; if ($blankquestionnaire) { $disabled = ' disabled="disabled"'; } - if (!empty($data) && (!isset($data->{'q' . $this->id}) || !is_array($data->{'q' . $this->id}))) { - $data->{'q' . $this->id} = []; + if (!empty($data) && (!isset($data->{'q' . $this->id()}) || !is_array($data->{'q' . $this->id()}))) { + $data->{'q' . $this->id()} = []; } // Check if rate question has one line only to display full width columns of choices. @@ -226,7 +229,7 @@ protected function question_survey_display($response, $descendantsdata, $blankqu } if ($nameddegrees == 0) { // Determine if the choices have named values. - $contents = questionnaire_choice_values($content); + $contents = question::parse_choice_content($content); if ($contents->modname) { $choice->content = $contents->text; } @@ -248,16 +251,16 @@ protected function question_survey_display($response, $descendantsdata, $blankqu $width = 30; } $nn = 100 - ($width * 2); - $colwidth = ($nn / $this->length) . '%'; + $colwidth = ($nn / $this->length()) . '%'; $textalign = 'right'; $width = $width . '%'; } else if ($nocontent) { $width = '0%'; - $colwidth = (100 / $this->length) . '%'; + $colwidth = (100 / $this->length()) . '%'; $textalign = 'right'; } else { $width = '59%'; - $colwidth = (40 / $this->length) . '%'; + $colwidth = (40 / $this->length()) . '%'; $textalign = 'left'; } @@ -268,16 +271,12 @@ protected function question_survey_display($response, $descendantsdata, $blankqu } else { $na = ''; } - if ($this->no_duplicate_choices()) { - $order = 'other_rate_uncheck(name, value)'; - } else { - $order = ''; - } + $rateuncheck = $this->no_duplicate_choices(); if (!$this->no_duplicate_choices()) { $nbchoices = count($this->choices); } else { // If "No duplicate choices", can restrict nbchoices to number of rate items specified. - $nbchoices = $this->length; + $nbchoices = $this->length(); } // Display empty td for Not yet answered column. @@ -289,7 +288,7 @@ protected function question_survey_display($response, $descendantsdata, $blankqu if ($nameddegrees > 0) { $currentdegree = reset($this->nameddegrees); } - for ($j = 1; $j <= $this->length; $j++) { + for ($j = 1; $j <= $this->length(); $j++) { $col = []; if (($nameddegrees > 0) && ($currentdegree !== false)) { $str = format_text($currentdegree, FORMAT_HTML, ['noclean' => true]); @@ -315,7 +314,7 @@ protected function question_survey_display($response, $descendantsdata, $blankqu $num = 0; foreach ($this->choices as $cid => $choice) { - $num += (isset($response->answers[$this->id][$cid]) && ($response->answers[$this->id][$cid]->value != -999)); + $num += (isset($response->answers[$this->id()][$cid]) && ($response->answers[$this->id()][$cid]->value != -999)); } $notcomplete = false; @@ -329,7 +328,7 @@ protected function question_survey_display($response, $descendantsdata, $blankqu foreach ($this->choices as $cid => $choice) { $cols = []; if (isset($choice->content)) { - $str = 'q' . "{$this->id}_$cid"; + $str = 'q' . "{$this->id()}_$cid"; $content = $choice->content; $rendercontent = format_text($choice->content, FORMAT_PLAIN); if ($this->osgood_rate_scale()) { @@ -339,7 +338,7 @@ protected function question_survey_display($response, $descendantsdata, $blankqu $othertext = $choice->other_choice_display(); $oname = $cid . '_qother'; $oid = $cid . '-other'; - $odata = isset($response->answers[$this->id][$cid]) ? $response->answers[$this->id][$cid]->value : ''; + $odata = isset($response->answers[$this->id()][$cid]) ? $response->answers[$this->id()][$cid]->value : ''; if (isset($odata)) { $ovalue = stripslashes($odata); } @@ -360,8 +359,8 @@ protected function question_survey_display($response, $descendantsdata, $blankqu $completeclass = 'notanswered'; $title = ''; if ( - $notcomplete && isset($response->answers[$this->id][$cid]) && - ($response->answers[$this->id][$cid]->value == -999) + $notcomplete && isset($response->answers[$this->id()][$cid]) && + ($response->answers[$this->id()][$cid]->value == -999) ) { $completeclass = 'notcompleted'; $title = get_string('pleasecomplete', 'questionnaire'); @@ -371,8 +370,8 @@ protected function question_survey_display($response, $descendantsdata, $blankqu if (!empty($checked)) { $colinput['checked'] = true; } - if (!empty($order)) { - $colinput['onclick'] = $order; + if ($rateuncheck) { + $colinput['rateuncheck'] = true; } $colinput['label'] = $this->set_label( $rowstart, @@ -387,7 +386,7 @@ protected function question_survey_display($response, $descendantsdata, $blankqu reset($this->nameddegrees); } $colstart = $hasnotansweredchoice ? self::COL_START + 1 : self::COL_START; - for ($j = 1; $j <= $this->length + $this->has_na_column(); $j++) { + for ($j = 1; $j <= $this->length() + $this->has_na_column(); $j++) { if (!isset($collabel[$j])) { // If not using this value, continue. continue; @@ -399,9 +398,9 @@ protected function question_survey_display($response, $descendantsdata, $blankqu $value = key($this->nameddegrees); next($this->nameddegrees); } else { - $value = ($j <= $this->length ? $j : -1); + $value = ($j <= $this->length() ? $j : -1); } - if (isset($response->answers[$this->id][$cid]) && ($value == $response->answers[$this->id][$cid]->value)) { + if (isset($response->answers[$this->id()][$cid]) && ($value == $response->answers[$this->id()][$cid]->value)) { $checked = ' checked="checked"'; } $col['colstyle'] = 'text-align:center'; @@ -416,8 +415,8 @@ protected function question_survey_display($response, $descendantsdata, $blankqu if (!empty($disabled)) { $col['colinput']['disabled'] = true; } - if (!empty($order)) { - $col['colinput']['onclick'] = $order; + if ($rateuncheck) { + $col['colinput']['rateuncheck'] = true; } $col['colinput']['label'] = $this->set_label($rowstart, $rendercontent, $colstart, $collabel[$j]); if ($bg == 'c0 raterow') { @@ -443,7 +442,7 @@ protected function question_survey_display($response, $descendantsdata, $blankqu /** * Return the context tags for the rate response template. - * @param \mod_questionnaire\responsetype\response\response $response + * @param \mod_questionnaire\local\response\response $response * @return \stdClass The rate question response context tags. * @throws \coding_exception */ @@ -454,8 +453,8 @@ protected function response_survey_display($response) { $resptags->headers = []; $resptags->rows = []; - if (!isset($response->answers[$this->id])) { - $response->answers[$this->id][] = new \mod_questionnaire\responsetype\answer\answer(); + if (!isset($response->answers[$this->id()])) { + $response->answers[$this->id()][] = new \mod_questionnaire\local\response\answer\answer(); } // Check if rate question has one line only to display full width columns of choices. $nocontent = false; @@ -486,18 +485,18 @@ protected function response_survey_display($response) { } $nn = 100 - ($sidecolwidthn * 2); $resptags->sidecolwidth = $sidecolwidth; - $resptags->colwidth = ($nn / $this->length) . '%'; + $resptags->colwidth = ($nn / $this->length()) . '%'; $resptags->textalign = 'right'; } else { $resptags->sidecolwidth = '49%'; - $resptags->colwidth = (50 / $this->length) . '%'; + $resptags->colwidth = (50 / $this->length()) . '%'; $resptags->textalign = 'left'; } if (!empty($this->nameddegrees)) { - $this->length = count($this->nameddegrees); + $this->set_length(count($this->nameddegrees)); reset($this->nameddegrees); } - for ($j = 1; $j <= $this->length; $j++) { + for ($j = 1; $j <= $this->length(); $j++) { $cellobj = new \stdClass(); $cellobj->bg = $bg; if (!empty($this->nameddegrees)) { @@ -524,9 +523,9 @@ protected function response_survey_display($response) { $rowobj = new \stdClass(); // Do not print column names if named column exist. if (!array_key_exists($cid, $cidnamed)) { - $str = 'q' . "{$this->id}_$cid"; + $str = 'q' . "{$this->id()}_$cid"; $content = $choice->content; - $contents = questionnaire_choice_values($content); + $contents = question::parse_choice_content($content); if ($contents->modname) { $content = $contents->text; } @@ -535,33 +534,34 @@ protected function response_survey_display($response) { } if ($choice->is_other_choice()) { $content = $choice->other_choice_display(); - if (isset($response->answers[$this->id][$cid]->otheresponse)) { - $rowobj->othercontent = $response->answers[$this->id][$cid]->otheresponse; + if (isset($response->answers[$this->id()][$cid]->otheresponse)) { + $rowobj->othercontent = $response->answers[$this->id()][$cid]->otheresponse; } } $rowobj->content = format_text($content, FORMAT_HTML, ['noclean' => true]) . ' '; $bg = 'c0'; $cols = []; if (!empty($this->nameddegrees)) { - $this->length = count($this->nameddegrees); + $this->set_length(count($this->nameddegrees)); reset($this->nameddegrees); } - for ($j = 1; $j <= $this->length; $j++) { + for ($j = 1; $j <= $this->length(); $j++) { $cellobj = new \stdClass(); - if (isset($response->answers[$this->id][$cid])) { + if (isset($response->answers[$this->id()][$cid])) { if (!empty($this->nameddegrees)) { - if ($response->answers[$this->id][$cid]->value == key($this->nameddegrees)) { + if ($response->answers[$this->id()][$cid]->value == key($this->nameddegrees)) { $cellobj->checked = 1; } next($this->nameddegrees); - } else if ($j == $response->answers[$this->id][$cid]->value) { + } else if ($j == $response->answers[$this->id()][$cid]->value) { $cellobj->checked = 1; } } $cellobj->str = $str . $j . $uniquetag++; $cellobj->bg = $bg; // N/A column checked. - $checkedna = (isset($response->answers[$this->id][$cid]) && ($response->answers[$this->id][$cid]->value == -1)); + $checkedna = (isset($response->answers[$this->id()][$cid]) && + ($response->answers[$this->id()][$cid]->value == -1)); if ($bg == 'c0') { $bg = 'c1'; } else { @@ -596,16 +596,16 @@ protected function response_survey_display($response) { * */ public function response_complete($responsedata) { - if (!is_a($responsedata, 'mod_questionnaire\responsetype\response\response')) { - $response = \mod_questionnaire\responsetype\response\response::response_from_webform($responsedata, [$this]); + if (!is_a($responsedata, 'mod_questionnaire\local\response\response')) { + $response = \mod_questionnaire\local\response\response::response_from_webform($responsedata, [$this]); } else { $response = $responsedata; } // To make it easier, create an array of answers by choiceid. $answers = []; - if (isset($response->answers[$this->id])) { - foreach ($response->answers[$this->id] as $answer) { + if (isset($response->answers[$this->id()])) { + foreach ($response->answers[$this->id()] as $answer) { $answers[$answer->choiceid] = $answer; } } @@ -646,8 +646,8 @@ public function response_complete($responsedata) { */ public function response_valid($responsedata) { // Work with a response object. - if (!is_a($responsedata, 'mod_questionnaire\responsetype\response\response')) { - $response = \mod_questionnaire\responsetype\response\response::response_from_webform($responsedata, [$this]); + if (!is_a($responsedata, 'mod_questionnaire\local\response\response')) { + $response = \mod_questionnaire\local\response\response::response_from_webform($responsedata, [$this]); } else { $response = $responsedata; } @@ -658,8 +658,8 @@ public function response_valid($responsedata) { // Create an answers array indexed by choiceid for ease. $answers = []; $nodups = []; - if (isset($response->answers[$this->id])) { - foreach ($response->answers[$this->id] as $answer) { + if (isset($response->answers[$this->id()])) { + foreach ($response->answers[$this->id()] as $answer) { $answers[$answer->choiceid] = $answer; $nodups[] = $answer->value; } @@ -681,9 +681,9 @@ public function response_valid($responsedata) { $nbchoices -= $nameddegrees; } // If nodupes and nb choice restricted, nbchoices may be > actual choices, so limit it to $question->length. - $isrestricted = ($this->length < count($this->choices)) && $this->no_duplicate_choices(); + $isrestricted = ($this->length() < count($this->choices)) && $this->no_duplicate_choices(); if ($isrestricted) { - $nbchoices = min($nbchoices, $this->length); + $nbchoices = min($nbchoices, $this->length()); } // Test for duplicate answers in a no duplicate question type. @@ -764,7 +764,7 @@ protected function form_preprocess_data($formdata) { foreach ($nameddegreelines as $nameddegreeline) { $nameddegreeline = trim($nameddegreeline); if ( - ($nameddegree = \mod_questionnaire\question\choice::content_is_named_degree_choice($nameddegreeline)) !== + ($nameddegree = \mod_questionnaire\local\question\choice::content_is_named_degree_choice($nameddegreeline)) !== false ) { $nameddegrees += $nameddegree; @@ -828,7 +828,7 @@ protected function form_preprocess_choicedata($formdata) { * @return bool */ public function update_choice($choicerecord) { - if ($nameddegree = \mod_questionnaire\question\choice::content_is_named_degree_choice($choicerecord->content)) { + if ($nameddegree = \mod_questionnaire\local\question\choice::content_is_named_degree_choice($choicerecord->content)) { // Preserve any existing value from the new array. $this->nameddegrees = $nameddegree + $this->nameddegrees; $this->insert_nameddegrees($this->nameddegrees); @@ -842,7 +842,7 @@ public function update_choice($choicerecord) { * @return bool */ public function add_choice($choicerecord) { - if ($nameddegree = \mod_questionnaire\question\choice::content_is_named_degree_choice($choicerecord->content)) { + if ($nameddegree = \mod_questionnaire\local\question\choice::content_is_named_degree_choice($choicerecord->content)) { // Preserve any existing value from the new array. $this->nameddegrees = $nameddegree + $this->nameddegrees; $this->insert_nameddegrees($this->nameddegrees); @@ -887,9 +887,9 @@ public function mobile_question_choices_display() { $cnum = 0; foreach ($this->choices as $choiceid => $choice) { $choice->na = false; - $choice->choice_id = $choiceid; + $choice->choiceid = $choiceid; $choice->id = $choiceid; - $choice->question_id = $this->id; + $choice->questionid = $this->id(); // Add a fieldkey for each choice. $choice->fieldkey = $this->mobile_fieldkey($choiceid); @@ -907,8 +907,8 @@ public function mobile_question_choices_display() { $choices[$cnum]->min = 0; $choices[$cnum]->minstr = 1; } - $choices[$cnum]->max = intval($this->length) - 1; - $choices[$cnum]->maxstr = intval($this->length); + $choices[$cnum]->max = intval($this->length()) - 1; + $choices[$cnum]->maxstr = intval($this->length()); } else if ($this->has_na_column()) { $choices[$cnum] = $choice; if ($this->required()) { @@ -918,7 +918,7 @@ public function mobile_question_choices_display() { $choices[$cnum]->min = 0; $choices[$cnum]->minstr = 1; } - $choices[$cnum]->max = intval($this->length); + $choices[$cnum]->max = intval($this->length()); $choices[$cnum]->na = true; } else { $excludes[$choiceid] = $choiceid; @@ -958,7 +958,7 @@ public function mobile_question_choices_display() { } if (!in_array($choiceid, $excludes)) { - $choice->choice_id = $choiceid; + $choice->choiceid = $choiceid; if ($choice->value == null) { $choice->value = ''; } @@ -981,7 +981,7 @@ public function mobile_question_rates_display() { $rates[] = (object)['value' => $value, 'label' => $label]; } } else { - for ($i = 1; $i <= $this->length; $i++) { + for ($i = 1; $i <= $this->length(); $i++) { $rates[] = (object)['value' => $i, 'label' => $i]; } } @@ -995,8 +995,8 @@ public function mobile_question_rates_display() { */ public function get_mobile_response_data($response) { $resultdata = []; - if (isset($response->answers[$this->id])) { - foreach ($response->answers[$this->id] as $answer) { + if (isset($response->answers[$this->id()])) { + foreach ($response->answers[$this->id()] as $answer) { // Add a fieldkey for each choice. if (!empty($this->nameddegrees)) { if (isset($this->nameddegrees[$answer->value])) { @@ -1016,8 +1016,8 @@ public function get_mobile_response_data($response) { * Add the nameddegrees property. */ private function add_nameddegrees_from_extradata() { - if (!empty($this->extradata)) { - $this->nameddegrees = json_decode($this->extradata, true); + if (!empty($this->extradata())) { + $this->nameddegrees = json_decode($this->extradata(), true); } } @@ -1038,8 +1038,6 @@ public function insert_nameddegrees(array $nameddegrees) { * @param \stdClass|null $questionrec */ public static function move_nameddegree_choices(int $qid = 0, ?\stdClass $questionrec = null) { - global $DB; - if ($qid !== 0) { $question = new rate($qid); } else { @@ -1064,24 +1062,17 @@ public static function move_nameddegree_choices(int $qid = 0, ?\stdClass $questi if ($question->insert_nameddegrees($nameddegrees)) { // Remove the old named desgree from the choices table. foreach ($oldchoiceids as $choiceid) { - \mod_questionnaire\question\choice::delete_from_db_by_id($choiceid); + \mod_questionnaire\local\question\choice::delete_from_db_by_id($choiceid); } - // First get all existing rank responses for this question. - $responses = $DB->get_recordset('questionnaire_response_rank', ['question_id' => $question->id]); - // Iterating over each response record ensures we won't change an existing record more than once. - foreach ($responses as $response) { - // Then, if the old value exists, set it to the new one. - if (isset($newvalues[$response->rankvalue])) { - $DB->set_field( - 'questionnaire_response_rank', - 'rankvalue', - $newvalues[$response->rankvalue], - ['id' => $response->id] - ); + // Translate every existing rank response for this question to its new value. + foreach (response_rank_record::get_for_question($question->id()) as $response) { + $oldvalue = $response->get('rankvalue'); + if (isset($newvalues[$oldvalue])) { + $response->set('rankvalue', $newvalues[$oldvalue]); + $response->update(); } } - $responses->close(); } } } @@ -1093,52 +1084,26 @@ public static function move_nameddegree_choices(int $qid = 0, ?\stdClass $questi * @param int|null $surveyid */ public static function move_all_nameddegree_choices(?int $surveyid = null) { - global $DB; - // This operation might take a while. Cancel PHP timeouts for this. \core_php_time_limit::raise(); - // First, let's adjust all rate answers from zero based to one based (see GHI223). - // If a specific survey is being dealt with, only use the questions from that survey. - $skip = false; - if ($surveyid !== null) { - $qids = $DB->get_records_menu( - 'questionnaire_question', - ['surveyid' => $surveyid, - 'type_id' => QUESRATE], - '', - 'id,surveyid' - ); - if (!empty($qids)) { - [$qsql, $qparams] = $DB->get_in_or_equal(array_keys($qids)); - } else { - // No relevant questions, so no need to do this step. - $skip = true; - } - } + $ratequests = question_record::get_for_type(QUESRATE, $surveyid); - // If we're doing this step, let's do it. - if (!$skip) { - $select = 'UPDATE {questionnaire_response_rank} ' . - 'SET rankvalue = (rankvalue + 1) ' . - 'WHERE (rankvalue >= 0)'; - if ($surveyid !== null) { - $select .= ' AND (question_id ' . $qsql . ')'; - } else { - $qparams = []; + // First, adjust all rate answers from zero based to one based (see GHI223). + // Restrict the bulk update to this survey's rate questions when one was supplied; + // skip entirely when the survey has none. + if ($surveyid !== null) { + if (empty($ratequests)) { + return; } - $DB->execute($select, $qparams); + response_rank_record::increment_rankvalues(array_map(fn($r) => $r->get('id'), $ratequests)); + } else { + response_rank_record::increment_rankvalues(); } - $args = ['type_id' => QUESRATE]; - if ($surveyid !== null) { - $args['surveyid'] = $surveyid; - } - $ratequests = $DB->get_recordset('questionnaire_question', $args); - foreach ($ratequests as $questionrec) { - self::move_nameddegree_choices(0, $questionrec); + foreach ($ratequests as $qrec) { + self::move_nameddegree_choices(0, $qrec->to_record()); } - $ratequests->close(); } /** diff --git a/classes/question/sectiontext.php b/classes/local/question/sectiontext.php similarity index 74% rename from classes/question/sectiontext.php rename to classes/local/question/sectiontext.php index 844590161..5e4d13592 100644 --- a/classes/question/sectiontext.php +++ b/classes/local/question/sectiontext.php @@ -14,9 +14,10 @@ // You should have received a copy of the GNU General Public License // along with Moodle. If not, see . -namespace mod_questionnaire\question; +namespace mod_questionnaire\local\question; -use mod_questionnaire\feedback\section; +use mod_questionnaire\local\db\feedback_section_record; +use mod_questionnaire\local\feedback\section; /** * This file contains the parent class for sectiontext question types. @@ -76,30 +77,30 @@ public function mobile_question_display($qnum, $autonum = false) { $options = ['noclean' => true, 'para' => false, 'filter' => true, 'context' => $this->context, 'overflowdiv' => true]; $mobiledata = (object)[ - 'id' => $this->id, - 'name' => $this->name, - 'type_id' => $this->type_id, - 'length' => $this->length, + 'id' => $this->id(), + 'name' => $this->name(), + 'typeid' => $this->typeid(), + 'length' => $this->length(), 'content' => format_text( file_rewrite_pluginfile_urls( - $this->content, + $this->content(), 'pluginfile.php', $this->context->id, 'mod_questionnaire', 'question', - $this->id + $this->id() ), FORMAT_HTML, $options ), - 'content_stripped' => strip_tags($this->content), + 'content_stripped' => strip_tags($this->content()), 'required' => false, - 'deleted' => $this->deleted, - 'response_table' => $this->responsetable, + 'deleted' => $this->deleted(), + 'response_table' => $this->questiontype ? $this->questiontype->responsetable : null, 'fieldkey' => $this->mobile_fieldkey(), - 'precise' => $this->precise, + 'precise' => $this->precise(), 'qnum' => '', - 'errormessage' => get_string('required') . ': ' . $this->name, + 'errormessage' => get_string('required') . ': ' . $this->name(), ]; $mobiledata->issectiontext = true; @@ -138,30 +139,30 @@ public function is_numbered() { /** * Return the context tags for the check question template. - * @param \mod_questionnaire\responsetype\response\response $response + * @param \mod_questionnaire\local\response\response $response * @param array $descendantsdata Array of all questions/choices depending on this question. * @param boolean $blankquestionnaire * @return object The check question context tags. * */ protected function question_survey_display($response, $descendantsdata, $blankquestionnaire = false) { - global $DB, $CFG, $PAGE; - require_once($CFG->dirroot . '/mod/questionnaire/questionnaire.class.php'); + // If not a response domain object, normal behavior as sectiontext question. + if (!($response instanceof \mod_questionnaire\local\response\response)) { + return ''; + } - // If !isset then normal behavior as sectiontext question. - if (!isset($response->questionnaireid)) { + // If no questionnaire context was passed through the rendering chain, fall back to normal behavior. + if ($this->questionnaire === null) { return ''; } $filteredsections = []; // In which section(s) is this question? - if ($fbsections = $DB->get_records('questionnaire_fb_sections', ['surveyid' => $this->surveyid])) { - foreach ($fbsections as $key => $fbsection) { - if ($scorecalculation = section::decode_scorecalculation($fbsection->scorecalculation)) { - if (array_key_exists($this->id, $scorecalculation)) { - array_push($filteredsections, $fbsection->section); - } + foreach (feedback_section_record::get_for_survey($this->surveyid()) as $fbsection) { + if ($scorecalculation = section::decode_scorecalculation($fbsection->get('scorecalculation'))) { + if (array_key_exists($this->id(), $scorecalculation)) { + array_push($filteredsections, $fbsection->get('section')); } } } @@ -171,27 +172,22 @@ protected function question_survey_display($response, $descendantsdata, $blankqu return ''; } - [$cm, $course, $questionnaire] = questionnaire_get_standard_page_items(null, $response->questionnaireid); - $questionnaire = new \questionnaire($course, $cm, 0, $questionnaire); - $questionnaire->add_renderer($PAGE->get_renderer('mod_questionnaire')); - $questionnaire->add_page(new \mod_questionnaire\output\reportpage()); - - $compare = false; - $allresponses = false; - $currentgroupid = 0; - $isgroupmember = false; - $rid = (isset($response->id) && !empty($response->id)) ? $response->id : 0; - $resps = [$rid => null]; - // For $filteredsections -> get the feedback messages only for this sections! - $feedbackmessages = $questionnaire->response_analysis( + global $PAGE; + $questionnaire = $this->questionnaire; + $rid = $response->id(); + // For $filteredsections -> get the feedback messages only for these sections! + $scoreboard = $questionnaire->feedback()->build_scoreboard( $rid, - $resps, - $compare, - $isgroupmember, - $allresponses, - $currentgroupid, + [$rid => null], + false, + false, + false, + 0, + '', + $PAGE->get_renderer('mod_questionnaire'), $filteredsections ); + $feedbackmessages = $scoreboard !== null ? $scoreboard->messages : []; // Output. $questiontags = new \stdClass(); diff --git a/classes/question/slider.php b/classes/local/question/slider.php similarity index 85% rename from classes/question/slider.php rename to classes/local/question/slider.php index f308a0b61..67814131c 100644 --- a/classes/question/slider.php +++ b/classes/local/question/slider.php @@ -14,7 +14,7 @@ // You should have received a copy of the GNU General Public License // along with Moodle. If not, see . -namespace mod_questionnaire\question; +namespace mod_questionnaire\local\question; /** * This file contains the parent class for slider question types. @@ -30,7 +30,7 @@ class slider extends question { * @return string */ protected function responseclass() { - return '\\mod_questionnaire\\responsetype\\slider'; + return '\\mod_questionnaire\\local\\response\\slider'; } /** @@ -79,10 +79,10 @@ public function supports_feedback() { * @return bool */ public function valid_feedback() { - $extradata = json_decode($this->extradata); + $extradata = json_decode($this->extradata()); $minrange = $extradata->minrange; // Negative scores are not accepted in Feedback. - return $this->supports_feedback() && !empty($this->name) && $minrange >= 0; + return $this->supports_feedback() && !empty($this->name()) && $minrange >= 0; } /** @@ -91,7 +91,7 @@ public function valid_feedback() { */ public function get_feedback_maxscore() { if ($this->valid_feedback()) { - $extradata = json_decode($this->extradata); + $extradata = json_decode($this->extradata()); $maxscore = $extradata->maxrange; } else { $maxscore = false; @@ -102,7 +102,7 @@ public function get_feedback_maxscore() { /** * Return the context tags for the check question template. * - * @param \mod_questionnaire\responsetype\response\response $response + * @param \mod_questionnaire\local\response\response $response * @param array $dependants Array of all questions/choices depending on this question. * @param boolean $blankquestionnaire * @return object The check question context tags. @@ -110,14 +110,21 @@ public function get_feedback_maxscore() { */ protected function question_survey_display($response, $dependants = [], $blankquestionnaire = false) { global $PAGE; - $PAGE->requires->js_init_call('M.mod_questionnaire.init_slider', null, false, questionnaire_get_js_module()); - $extradata = json_decode($this->extradata); + $PAGE->requires->strings_for_js([ + 'leftpart', 'leftpartdefault', + 'middlepart', 'middlepartdefault', + 'middlepartwithtwovalues', 'middlepartwithtwovaluesdefault', + 'rightpart', 'rightpartdefault', + 'where', + ], 'questionnaire'); + $PAGE->requires->js_call_amd('mod_questionnaire/slider', 'init'); + $extradata = json_decode($this->extradata()); $questiontags = new \stdClass(); - if (isset($response->answers[$this->id][0])) { - $extradata->startingvalue = $response->answers[$this->id][0]->value; + if (isset($response->answers[$this->id()][0])) { + $extradata->startingvalue = $response->answers[$this->id()][0]->value; } - $extradata->name = 'q' . $this->id; - $extradata->id = self::qtypename($this->type_id) . $this->id; + $extradata->name = 'q' . $this->id(); + $extradata->id = self::qtypename($this->typeid()) . $this->id(); $questiontags->qelements = new \stdClass(); $questiontags->qelements->extradata = $extradata; $questiontags->isprint = $this->get_isprint(); @@ -126,21 +133,28 @@ protected function question_survey_display($response, $dependants = [], $blankqu /** * Return the context tags for the slider response template. - * @param \mod_questionnaire\responsetype\response\response $response + * @param \mod_questionnaire\local\response\response $response * @return \stdClass The check question response context tags. */ protected function response_survey_display($response) { global $PAGE; - $PAGE->requires->js_init_call('M.mod_questionnaire.init_slider', null, false, questionnaire_get_js_module()); + $PAGE->requires->strings_for_js([ + 'leftpart', 'leftpartdefault', + 'middlepart', 'middlepartdefault', + 'middlepartwithtwovalues', 'middlepartwithtwovaluesdefault', + 'rightpart', 'rightpartdefault', + 'where', + ], 'questionnaire'); + $PAGE->requires->js_call_amd('mod_questionnaire/slider', 'init'); $resptags = new \stdClass(); - if (isset($response->answers[$this->id])) { - $answer = reset($response->answers[$this->id]); + if (isset($response->answers[$this->id()])) { + $answer = reset($response->answers[$this->id()]); $resptags->content = format_text($answer->value, FORMAT_HTML); - if (!empty($response->answers[$this->id]['extradata'])) { - $resptags->extradata = $response->answers[$this->id]['extradata']; + if (!empty($response->answers[$this->id()]['extradata'])) { + $resptags->extradata = $response->answers[$this->id()]['extradata']; } else { - $extradata = json_decode($this->extradata); + $extradata = json_decode($this->extradata()); $resptags->extradata = $extradata; } } @@ -189,8 +203,8 @@ protected function form_extradata(\MoodleQuickForm $mform, $helpname = '') { $stepvalue = 'stepvalue'; $ranges = []; - if (!empty($this->extradata)) { - $ranges = json_decode($this->extradata); + if (!empty($this->extradata())) { + $ranges = json_decode($this->extradata()); } $mform->addElement('text', 'leftlabel', get_string('leftlabel', 'questionnaire')); $mform->setType('leftlabel', PARAM_RAW); @@ -331,7 +345,7 @@ public function mobile_question_display($qnum, $autonum = false) { * @return array */ public function mobile_otherdata() { - $extradata = json_decode($this->extradata); + $extradata = json_decode($this->extradata()); return [$this->mobile_fieldkey() => $extradata->startingvalue]; } } diff --git a/classes/question/text.php b/classes/local/question/text.php similarity index 86% rename from classes/question/text.php rename to classes/local/question/text.php index 40d990fa7..084ccddcc 100644 --- a/classes/question/text.php +++ b/classes/local/question/text.php @@ -14,7 +14,7 @@ // You should have received a copy of the GNU General Public License // along with Moodle. If not, see . -namespace mod_questionnaire\question; +namespace mod_questionnaire\local\question; /** * This file contains the parent class for text question types. @@ -33,8 +33,8 @@ class text extends question { * @param array $params */ public function __construct($id = 0, $question = null, $context = null, $params = []) { - $this->length = 20; - $this->precise = 25; + $this->set_length(20); + $this->set_precise(25); return parent::__construct($id, $question, $context, $params); } @@ -43,7 +43,7 @@ public function __construct($id = 0, $question = null, $context = null, $params * @return object The response object based off of questionnaire_response_base. */ protected function responseclass() { - return '\\mod_questionnaire\\responsetype\\text'; + return '\\mod_questionnaire\\local\\response\\text'; } /** @@ -83,14 +83,14 @@ protected function question_survey_display($response, $descendantsdata, $blankqu $questiontags->qelements = new \stdClass(); $choice = new \stdClass(); $choice->onkeypress = 'return event.keyCode != 13;'; - $choice->size = $this->length; - $choice->name = 'q' . $this->id; - if ($this->precise > 0) { - $choice->maxlength = $this->precise; + $choice->size = $this->length(); + $choice->name = 'q' . $this->id(); + if ($this->precise() > 0) { + $choice->maxlength = $this->precise(); } - $choice->value = (isset($response->answers[$this->id][0]) ? - format_string(stripslashes($response->answers[$this->id][0]->value)) : ''); - $choice->id = self::qtypename($this->type_id) . $this->id; + $choice->value = (isset($response->answers[$this->id()][0]) ? + format_string(stripslashes($response->answers[$this->id()][0]->value)) : ''); + $choice->id = self::qtypename($this->typeid()) . $this->id(); $questiontags->qelements->choice = $choice; $questiontags->isprint = $this->get_isprint(); return $questiontags; @@ -102,8 +102,8 @@ protected function question_survey_display($response, $descendantsdata, $blankqu */ protected function response_survey_display($response) { $resptags = new \stdClass(); - if (isset($response->answers[$this->id])) { - $answer = reset($response->answers[$this->id]); + if (isset($response->answers[$this->id()])) { + $answer = reset($response->answers[$this->id()]); $resptags->content = format_text($answer->value, FORMAT_HTML); } return $resptags; @@ -155,8 +155,8 @@ public function mobile_question_choices_display() { $choices = []; $choices[0] = new \stdClass(); $choices[0]->id = 0; - $choices[0]->choice_id = 0; - $choices[0]->question_id = $this->id; + $choices[0]->choiceid = 0; + $choices[0]->questionid = $this->id(); $choices[0]->content = ''; $choices[0]->value = null; return $choices; diff --git a/classes/question/yesno.php b/classes/local/question/yesno.php similarity index 86% rename from classes/question/yesno.php rename to classes/local/question/yesno.php index 3c9f51268..8dda77d2e 100644 --- a/classes/question/yesno.php +++ b/classes/local/question/yesno.php @@ -14,7 +14,7 @@ // You should have received a copy of the GNU General Public License // along with Moodle. If not, see . -namespace mod_questionnaire\question; +namespace mod_questionnaire\local\question; /** * This file contains the parent class for yesno question types. @@ -30,7 +30,7 @@ class yesno extends question { * @return object The response object based off of questionnaire_response_base. */ protected function responseclass() { - return '\\mod_questionnaire\\responsetype\\boolean'; + return '\\mod_questionnaire\\local\\response\\boolean'; } /** @@ -101,16 +101,16 @@ public function get_feedback_maxscore() { */ protected function get_dependency_options() { $options = []; - if ($this->name != '') { - $options[$this->id . ',0'] = $this->name . '->' . get_string('yes'); - $options[$this->id . ',1'] = $this->name . '->' . get_string('no'); + if ($this->name() != '') { + $options[$this->id() . ',0'] = $this->name() . '->' . get_string('yes'); + $options[$this->id() . ',1'] = $this->name() . '->' . get_string('no'); } return $options; } /** * Return the context tags for the check question template. - * @param \mod_questionnaire\responsetype\response\response $response + * @param \mod_questionnaire\local\response\response $response * @param array $dependants Array of all questions/choices depending on this question. * @param boolean $blankquestionnaire * @return object The check question context tags. @@ -131,8 +131,8 @@ protected function question_survey_display($response, $dependants = [], $blankqu } $options = [$val1 => $stryes, $val2 => $strno]; - $name = 'q' . $this->id; - $checked = (isset($response->answers[$this->id][0]) ? $response->answers[$this->id][0]->value : ''); + $name = 'q' . $this->id(); + $checked = (isset($response->answers[$this->id()][0]) ? $response->answers[$this->id()][0]->value : ''); $ischecked = false; $choicetags = new \stdClass(); @@ -183,7 +183,7 @@ protected function question_survey_display($response, $dependants = [], $blankqu /** * Return the context tags for the text response template. - * @param \mod_questionnaire\responsetype\response\response $response + * @param \mod_questionnaire\local\response\response $response * @return object The radio question response context tags. * @throws \coding_exception */ @@ -192,14 +192,14 @@ protected function response_survey_display($response) { $resptags = new \stdClass(); - $resptags->yesname = 'q' . $this->id . $uniquetag++ . 'y'; - $resptags->noname = 'q' . $this->id . $uniquetag++ . 'n'; + $resptags->yesname = 'q' . $this->id() . $uniquetag++ . 'y'; + $resptags->noname = 'q' . $this->id() . $uniquetag++ . 'n'; $resptags->stryes = get_string('yes'); $resptags->strno = get_string('no'); - if (!isset($response->answers[$this->id])) { - $response->answers[$this->id][] = new \mod_questionnaire\responsetype\answer\answer(); + if (!isset($response->answers[$this->id()])) { + $response->answers[$this->id()][] = new \mod_questionnaire\local\response\answer\answer(); } - $answer = reset($response->answers[$this->id]); + $answer = reset($response->answers[$this->id()]); if ($answer->value == 'y') { $resptags->yesselected = 1; } @@ -261,15 +261,15 @@ public function mobile_question_choices_display() { $choices = []; $choices[0] = new \stdClass(); $choices[0]->id = 0; - $choices[0]->choice_id = 'n'; - $choices[0]->question_id = $this->id; + $choices[0]->choiceid = 'n'; + $choices[0]->questionid = $this->id(); $choices[0]->value = null; $choices[0]->content = get_string('no'); $choices[0]->isbool = true; $choices[1] = new \stdClass(); $choices[1]->id = 1; - $choices[1]->choice_id = 'y'; - $choices[1]->question_id = $this->id; + $choices[1]->choiceid = 'y'; + $choices[1]->questionid = $this->id(); $choices[1]->value = null; $choices[1]->content = get_string('yes'); $choices[1]->isbool = true; @@ -288,9 +288,9 @@ public function mobile_question_choices_display() { */ public function get_mobile_response_data($response) { $resultdata = []; - if (isset($response->answers[$this->id][0]) && ($response->answers[$this->id][0]->value == 'n')) { + if (isset($response->answers[$this->id()][0]) && ($response->answers[$this->id()][0]->value == 'n')) { $resultdata[$this->mobile_fieldkey()] = 0; - } else if (isset($response->answers[$this->id][0]) && ($response->answers[$this->id][0]->value == 'y')) { + } else if (isset($response->answers[$this->id()][0]) && ($response->answers[$this->id()][0]->value == 'y')) { $resultdata[$this->mobile_fieldkey()] = 1; } diff --git a/classes/local/question_navigator.php b/classes/local/question_navigator.php new file mode 100644 index 000000000..4e17a9133 --- /dev/null +++ b/classes/local/question_navigator.php @@ -0,0 +1,264 @@ +. + +namespace mod_questionnaire\local; + +use mod_questionnaire\local\survey\survey; +use mod_questionnaire\local\question\question; +use stdClass; + +/** + * Stateless navigator for moving through a survey's pages and questions. + * + * Encapsulates page-traversal and dependency-inspection logic that previously + * lived on the survey and questionnaire classes. Accepts the survey (for + * question/page structure) and a skip-logic flag (from the questionnaire module + * record) at construction time, then reads question state fresh on each call. + * + * @package mod_questionnaire + * @copyright 2026 onward Mike Churchward (mike.churchward@poetopensource.org) + * @author Mike Churchward + * @license http://www.gnu.org/copyleft/gpl.html GNU Public License + */ +class question_navigator { + /** @var survey The survey whose pages and questions are navigated. */ + private survey $survey; + + /** @var bool True when the questionnaire module has navigate > 0. */ + private bool $skiplogicenabled; + + /** + * Construct a navigator for the given survey. + * + * @param survey $survey The survey whose pages and questions are navigated. + * @param bool $skiplogicenabled True when the questionnaire module has navigate=1. + */ + public function __construct(survey $survey, bool $skiplogicenabled) { + $this->survey = $survey; + $this->skiplogicenabled = $skiplogicenabled; + } + + // Dependency inspection. + + /** + * True if skip-logic is enabled and at least one question carries a dependency condition. + * + * @return bool + */ + public function has_dependencies(): bool { + if (!$this->skiplogicenabled) { + return false; + } + foreach ($this->survey->questions() as $question) { + if ($question->has_dependencies()) { + return true; + } + } + return false; + } + + /** + * Get the IDs of all questions that depend on the given question. + * + * @param int $questionid + * @return array + */ + public function get_dependants(int $questionid): array { + $qu = []; + foreach ($this->survey->questions() as $question) { + if ($question->has_dependencies()) { + foreach ($question->dependencies as $dependency) { + if (($dependency->dependquestionid == $questionid) && !in_array($question->id(), $qu)) { + $qu[] = $question->id(); + } + } + } + } + return $qu; + } + + /** + * Get all direct and indirect dependants of a question. + * + * @param int $questionid + * @return stdClass Object with ->directs and ->indirects arrays. + */ + public function get_all_dependants(int $questionid): stdClass { + $questions = $this->survey->questions(); + $directids = $this->get_dependants($questionid); + $directs = []; + $indirects = []; + foreach ($directids as $directid) { + $this->load_parents($questions[$directid]); + $indirectids = $this->get_dependants($directid); + foreach ($questions[$directid]->dependencies as $dep) { + if ($dep->dependquestionid == $questionid) { + $directs[$directid][] = $dep; + } + } + foreach ($indirectids as $indirectid) { + $this->load_parents($questions[$indirectid]); + foreach ($questions[$indirectid]->dependencies as $dep) { + if ($dep->dependquestionid != $questionid) { + $indirects[$indirectid][] = $dep; + } + } + } + } + $alldependants = new stdClass(); + $alldependants->directs = $directs; + $alldependants->indirects = $indirects; + return $alldependants; + } + + /** + * Get all descendants and their choice conditions, keyed by parent question id. + * + * @return array + */ + public function get_dependants_and_choices(): array { + $questions = array_reverse($this->survey->questions(), true); + $parents = []; + foreach ($questions as $question) { + foreach ($question->dependencies as $dependency) { + $child = new stdClass(); + $child->choiceid = $dependency->dependchoiceid; + $child->logic = $dependency->dependlogic; + $child->andor = $dependency->dependandor; + $parents[$dependency->dependquestionid][$question->id()][] = $child; + } + } + return $parents; + } + + /** + * Load display info for each dependency of a question (parent name, choice label, etc.). + * + * @param question $question + * @return bool + */ + public function load_parents(question $question): bool { + $questions = $this->survey->questions(); + foreach ($question->dependencies as $did => $dependency) { + $dependquestion = $questions[$dependency->dependquestionid]; + $qdependchoice = ''; + switch ($dependquestion->typeid()) { + case QUESRADIO: + case QUESDROP: + case QUESCHECK: + $qdependchoice = $dependency->dependchoiceid; + $dependchoice = $dependquestion->choices[$dependency->dependchoiceid]->content; + $contents = question::parse_choice_content($dependchoice); + if ($contents->modname) { + $dependchoice = $contents->modname; + } + break; + case QUESYESNO: + switch ($dependency->dependchoiceid) { + case 0: + $dependchoice = get_string('yes'); + $qdependchoice = 'y'; + break; + case 1: + $dependchoice = get_string('no'); + $qdependchoice = 'n'; + break; + default: + $dependchoice = ''; + } + break; + default: + $dependchoice = ''; + } + $question->dependencies[$did]->qdependquestion = 'q' . $dependquestion->id(); + $question->dependencies[$did]->qdependchoice = $qdependchoice; + $question->dependencies[$did]->parenttype = $dependquestion->typeid(); + $question->dependencies[$did]->position = $question->position(); + $question->dependencies[$did]->name = $question->name(); + $question->dependencies[$did]->content = $question->content(); + $question->dependencies[$did]->parentposition = $dependquestion->position(); + $question->dependencies[$did]->parent = + format_string($dependquestion->name()) . '->' . format_string($dependchoice); + } + return true; + } + + // Page navigation. + + /** + * True if there are any eligible (dependency-satisfied) questions on the given section. + * + * @param int $secnum 1-based section number. + * @param int $rid Response id (0 if no response yet). + * @return bool + */ + public function eligible_questions_on_page(int $secnum, int $rid): bool { + $questions = $this->survey->questions(); + foreach ($this->survey->questions_by_section($secnum) as $question) { + if ($question->dependency_fulfilled($rid, $questions)) { + return true; + } + } + return false; + } + + /** + * Return the next valid section number after $secnum, or false if none. + * + * When skip-logic is active, skips pages whose questions are all dependency-blocked. + * + * @param int $secnum Current section number. + * @param int $rid Response id. + * @return int|bool + */ + public function next_page(int $secnum, int $rid): int|bool { + $secnum++; + $questionsbysec = $this->survey->questions_by_section_all(); + $numsections = !empty($questionsbysec) ? count($questionsbysec) : 0; + if ($this->has_dependencies()) { + while (!$this->eligible_questions_on_page($secnum, $rid)) { + $secnum++; + if ($secnum > $numsections) { + $secnum = false; + break; + } + } + } + return $secnum; + } + + /** + * Return the previous valid section number before $secnum, or false if none. + * + * When skip-logic is active, skips pages whose questions are all dependency-blocked. + * + * @param int $secnum Current section number. + * @param int $rid Response id. + * @return int|bool + */ + public function prev_page(int $secnum, int $rid): int|bool { + $secnum--; + if ($this->has_dependencies()) { + while (($secnum > 0) && !$this->eligible_questions_on_page($secnum, $rid)) { + $secnum--; + } + } + if ($secnum === 0) { + $secnum = false; + } + return $secnum; + } +} diff --git a/classes/local/question_type.php b/classes/local/question_type.php new file mode 100644 index 000000000..6411720de --- /dev/null +++ b/classes/local/question_type.php @@ -0,0 +1,177 @@ +. + +namespace mod_questionnaire\local; + +use mod_questionnaire\local\db\question_type_record; + +/** + * The main class for question type definitions. + * + * Replaces the scattered define() constants throughout the plugin with typed + * class constants and a DB-backed factory. All code referencing QUESCHOOSE etc. + * should migrate to question_type::QUESCHOOSE. + * + * @package mod_questionnaire + * @copyright 2025 onward Mike Churchward (mike.churchward@poetgroup.org) + * @author Mike Churchward + * @license http://www.gnu.org/copyleft/gpl.html GNU Public License + */ +class question_type { + /** @var int Define choose question type. */ + const QUESCHOOSE = 0; + /** @var int Define Yes/No question type. */ + const QUESYESNO = 1; + /** @var int Define text question type. */ + const QUESTEXT = 2; + /** @var int Define essay question type. */ + const QUESESSAY = 3; + /** @var int Define radio question type. */ + const QUESRADIO = 4; + /** @var int Define check question type. */ + const QUESCHECK = 5; + /** @var int Define drop question type. */ + const QUESDROP = 6; + /** @var int Define rate question type. */ + const QUESRATE = 8; + /** @var int Define date question type. */ + const QUESDATE = 9; + /** @var int Define numeric question type. */ + const QUESNUMERIC = 10; + /** @var int Define slider question type. */ + const QUESSLIDER = 11; + /** @var int Define file question type. */ + const QUESFILE = 12; + /** @var int Define page break question type. */ + const QUESPAGEBREAK = 99; + /** @var int Define section text question type. */ + const QUESSECTIONTEXT = 100; + + /** @var string The type name. */ + public string $type; + + /** @var string Whether the type has choices ('y' or 'n'). */ + public string $haschoices; + + /** @var string|null The response table name. */ + public ?string $responsetable; + + /** @var array Per-request cache of question_type instances keyed by typeid. */ + private static array $typecache = []; + + /** @var array Maps typeid integer → short name string. */ + private static array $qtypenames = [ + self::QUESYESNO => 'yesno', + self::QUESTEXT => 'text', + self::QUESESSAY => 'essay', + self::QUESRADIO => 'radio', + self::QUESCHECK => 'check', + self::QUESDROP => 'drop', + self::QUESRATE => 'rate', + self::QUESDATE => 'date', + self::QUESFILE => 'file', + self::QUESNUMERIC => 'numerical', + self::QUESPAGEBREAK => 'pagebreak', + self::QUESSECTIONTEXT => 'sectiontext', + self::QUESSLIDER => 'slider', + ]; + + /** + * Construct from a question_type_record persistent. + * + * @param question_type_record $qtyperecord + */ + public function __construct(question_type_record $qtyperecord) { + $this->type = $qtyperecord->get('type'); + $this->haschoices = $qtyperecord->get('haschoices'); + $this->responsetable = $qtyperecord->get('responsetable'); + } + + /** + * Return a question_type instance for the given typeid, or null if the typeid + * does not exist in the questionnaire_question_type table. + * + * Results are cached per request so the DB is queried at most once per typeid. + * + * @param int $typeid + * @return question_type|null + */ + public static function from_typeid(int $typeid): ?self { + if (!array_key_exists($typeid, self::$typecache)) { + $record = question_type_record::from_typeid($typeid); + self::$typecache[$typeid] = $record !== null ? new self($record) : null; + } + return self::$typecache[$typeid]; + } + + /** + * Return the short name for a question type integer. + * + * @param int $qtype + * @return string Empty string if the typeid is not known. + */ + public static function qtypename(int $qtype): string { + return self::$qtypenames[$qtype] ?? ''; + } + + /** + * Return the full map of typeid => short name. + * + * @return array + */ + public static function qtypenames(): array { + return self::$qtypenames; + } + + /** + * Return the localised display name for a question type integer. + * + * @param int $typeid + * @return string Empty string if the typeid is not known. + */ + public static function display_name(int $typeid): string { + switch ($typeid) { + case self::QUESYESNO: + return get_string('yesno', 'questionnaire'); + case self::QUESTEXT: + return get_string('textbox', 'questionnaire'); + case self::QUESESSAY: + return get_string('essaybox', 'questionnaire'); + case self::QUESRADIO: + return get_string('radiobuttons', 'questionnaire'); + case self::QUESCHECK: + return get_string('checkboxes', 'questionnaire'); + case self::QUESDROP: + return get_string('dropdown', 'questionnaire'); + case self::QUESRATE: + return get_string('ratescale', 'questionnaire'); + case self::QUESDATE: + return get_string('date', 'questionnaire'); + case self::QUESNUMERIC: + return get_string('numeric', 'questionnaire'); + case self::QUESSLIDER: + return get_string('slider', 'questionnaire'); + case self::QUESFILE: + return get_string('file', 'questionnaire'); + case self::QUESSECTIONTEXT: + return get_string('sectiontext', 'questionnaire'); + case self::QUESPAGEBREAK: + return get_string('sectionbreak', 'questionnaire'); + default: + return ''; + } + } +} diff --git a/classes/local/report/report_actions.php b/classes/local/report/report_actions.php new file mode 100644 index 000000000..50f8c8eb1 --- /dev/null +++ b/classes/local/report/report_actions.php @@ -0,0 +1,309 @@ +. + +namespace mod_questionnaire\local\report; + +use mod_questionnaire\local\db\response_record; +use mod_questionnaire\questionnaire; + +/** + * Controller for staff-side report actions on report.php. + * + * Each method owns one of the four deletion-related action arms: + * confirm-delete-single, delete-single, confirm-delete-all, delete-all. + * + * Confirm methods render their own confirmation page (capability check, + * tabs, confirm dialog) and return; delete methods either redirect on + * success or throw a plugin moodle_exception on failure. + * + * @package mod_questionnaire + * @copyright 2026 Mike Churchward (mike.churchward@poetopensource.org) + * @author Mike Churchward + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class report_actions { + /** + * Constructor. + * + * @param questionnaire $questionnaire The active questionnaire. + * @param \plugin_renderer_base $renderer Plugin renderer for confirmation pages. + */ + public function __construct( + /** @var questionnaire The active questionnaire. */ + private readonly questionnaire $questionnaire, + /** @var \plugin_renderer_base Plugin renderer for confirmation pages. */ + private readonly \plugin_renderer_base $renderer, + ) { + } + + /** + * Render the "delete this response?" confirmation page. + * + * Side effects: sets $PAGE title/heading, echoes header/page/footer. + * + * @param object $page Templatable page object (reportpage). + * @param int $rid Response id to delete. + * @param int $currentgroupid Group filter context. + * @return void + */ + public function confirm_delete_response(object $page, int $rid, int $currentgroupid): void { + global $PAGE; + + require_capability('mod/questionnaire:deleteresponses', $this->questionnaire->context()); + + if (!$this->questionnaire->survey()) { + throw new \moodle_exception('surveynotexists', 'mod_questionnaire'); + } else if ($this->questionnaire->survey()->owning_courseid() != $this->questionnaire->course()->id) { + throw new \moodle_exception('surveyowner', 'mod_questionnaire'); + } else if (!$rid) { + throw new \moodle_exception('invalidresponse', 'mod_questionnaire'); + } else if (!($resp = response_record::get_or_null($rid))) { + throw new \moodle_exception('invalidresponserecord', 'mod_questionnaire'); + } + + $ruser = $this->describe_respondent((int)$resp->get('userid')); + $timesubmitted = '
' . get_string('submitted', 'questionnaire') . ' ' . userdate($resp->get('submitted')); + if ($this->questionnaire->respondenttype() == 'anonymous') { + $ruser = '- ' . get_string('anonymous', 'questionnaire') . ' -'; + $timesubmitted = ''; + } + + $PAGE->set_title(get_string('deletingresp', 'questionnaire')); + $PAGE->set_heading(format_string($this->questionnaire->course()->fullname)); + echo $this->renderer->header(); + + (new \mod_questionnaire\output\tabs($this->questionnaire, 'deleteresp', $currentgroupid, $rid))->render($page); + + $instance = $this->questionnaire->id(); + $msg = '
'; + $msg .= get_string('confirmdelresp', 'questionnaire', $ruser . $timesubmitted); + $msg .= '
'; + $urlyes = new \moodle_url('report.php', [ + 'action' => 'dvresp', + 'rid' => $rid, + 'individualresponse' => 1, + 'instance' => $instance, + 'group' => $currentgroupid, + ]); + $urlno = new \moodle_url('report.php', [ + 'action' => 'vresp', + 'instance' => $instance, + 'rid' => $rid, + 'individualresponse' => 1, + 'group' => $currentgroupid, + ]); + $buttonyes = new \single_button($urlyes, get_string('delete'), 'post'); + $buttonno = new \single_button($urlno, get_string('cancel'), 'get'); + $page->add_to_page('notifications', $this->renderer->confirm($msg, $buttonyes, $buttonno)); + echo $this->renderer->render($page); + echo $this->renderer->footer($this->questionnaire->course()); + } + + /** + * Execute the delete of a single response. Redirects on success, throws on failure. + * + * @param int $rid Response id to delete. + * @return void + */ + public function delete_response(int $rid): void { + global $CFG; + + require_capability('mod/questionnaire:deleteresponses', $this->questionnaire->context()); + + if (!$this->questionnaire->survey()) { + throw new \moodle_exception('surveynotexists', 'mod_questionnaire'); + } else if ($this->questionnaire->survey()->owning_courseid() != $this->questionnaire->course()->id) { + throw new \moodle_exception('surveyowner', 'mod_questionnaire'); + } else if (!$rid) { + throw new \moodle_exception('invalidresponse', 'mod_questionnaire'); + } else if (!($response = response_record::get_or_null($rid))) { + throw new \moodle_exception('invalidresponserecord', 'mod_questionnaire'); + } + + $responseuserid = (int)$response->get('userid'); + $instance = $this->questionnaire->id(); + + if ($this->questionnaire->responses()->delete_response($response->to_record())) { + if (!response_record::count_complete_for_questionnaire($instance)) { + $redirection = $CFG->wwwroot . '/mod/questionnaire/view.php?id=' . + $this->questionnaire->coursemodule()->id; + } else { + $redirection = $CFG->wwwroot . '/mod/questionnaire/report.php?action=vresp&instance=' . + $instance . '&byresponse=1'; + } + + $event = \mod_questionnaire\event\response_deleted::create([ + 'objectid' => $this->questionnaire->surveyid(), + 'context' => $this->questionnaire->context(), + 'courseid' => $this->questionnaire->courseid(), + 'relateduserid' => $responseuserid, + ]); + $event->trigger(); + + redirect($redirection); + } + + $ruser = ($this->questionnaire->respondenttype() == 'anonymous') + ? '- ' . get_string('anonymous', 'questionnaire') . ' -' + : $this->describe_respondent($responseuserid); + $link = new \moodle_url('/mod/questionnaire/report.php', [ + 'action' => 'vresp', + 'sid' => $this->questionnaire->surveyid(), + 'instance' => $instance, + 'byresponse' => '1', + ]); + throw new \moodle_exception( + 'couldnotdelrespby', + 'mod_questionnaire', + $link, + ['rid' => $rid, 'user' => $ruser] + ); + } + + /** + * Render the "delete all responses?" confirmation page. + * + * Renders nothing if there are no participants to delete (mirrors original arm behaviour). + * + * @param object $page Templatable page object (reportpage). + * @param int $groupmode Activity group mode (0/1/2). + * @param int $currentgroupid Active group filter. + * @param string $groupname Pre-formatted group label for the confirm message. + * @param array $respsallparticipants Responses to participants in scope. + * @return void + */ + public function confirm_delete_all_responses( + object $page, + int $groupmode, + int $currentgroupid, + string $groupname, + array $respsallparticipants + ): void { + global $PAGE; + + require_capability('mod/questionnaire:deleteresponses', $this->questionnaire->context()); + + if (empty($respsallparticipants)) { + return; + } + + $PAGE->set_title(get_string('deletingresp', 'questionnaire')); + $PAGE->set_heading(format_string($this->questionnaire->course()->fullname)); + echo $this->renderer->header(); + + (new \mod_questionnaire\output\tabs($this->questionnaire, 'deleteall', $currentgroupid))->render($page); + + $instance = $this->questionnaire->id(); + $msg = '
'; + if ($groupmode == 0) { + $msg .= get_string('confirmdelallresp', 'questionnaire'); + } else { + $msg .= get_string('confirmdelgroupresp', 'questionnaire', $groupname); + } + $msg .= '
'; + + $urlyes = new \moodle_url('report.php', [ + 'action' => 'dvallresp', + 'sid' => $this->questionnaire->surveyid(), + 'instance' => $instance, + 'group' => $currentgroupid, + ]); + $urlno = new \moodle_url('report.php', ['instance' => $instance, 'group' => $currentgroupid]); + $buttonyes = new \single_button($urlyes, get_string('delete'), 'post'); + $buttonno = new \single_button($urlno, get_string('cancel'), 'get'); + + $page->add_to_page('notifications', $this->renderer->confirm($msg, $buttonyes, $buttonno)); + echo $this->renderer->render($page); + echo $this->renderer->footer($this->questionnaire->course()); + } + + /** + * Execute the delete of all responses (optionally group-scoped). Redirects on success. + * + * @param int $groupmode Activity group mode. + * @param int $currentgroupid Active group filter. + * @param array $respsallparticipants Pre-computed all-participants responses. + * @return void + */ + public function delete_all_responses( + int $groupmode, + int $currentgroupid, + array $respsallparticipants + ): void { + global $CFG; + + require_capability('mod/questionnaire:deleteresponses', $this->questionnaire->context()); + + if (!$this->questionnaire->survey()) { + throw new \moodle_exception('surveynotexists', 'mod_questionnaire'); + } else if ($this->questionnaire->survey()->owning_courseid() != $this->questionnaire->course()->id) { + throw new \moodle_exception('surveyowner', 'mod_questionnaire'); + } + + if ($groupmode > 0 && $currentgroupid > 0) { + $resps = $this->questionnaire->get_responses(false, $currentgroupid) ?: []; + } else { + $resps = $respsallparticipants; + } + + $instance = $this->questionnaire->id(); + if (!empty($resps)) { + foreach ($resps as $response) { + $this->questionnaire->responses()->delete_response($response); + } + $sid = $this->questionnaire->surveyid(); + if (!$this->questionnaire->count_submissions()) { + $redirection = $CFG->wwwroot . '/mod/questionnaire/view.php?id=' . + $this->questionnaire->coursemodule()->id; + } else { + $redirection = $CFG->wwwroot . '/mod/questionnaire/report.php?action=vall&sid=' . + $sid . '&instance=' . $instance; + } + + $event = \mod_questionnaire\event\all_responses_deleted::create([ + 'objectid' => $instance, + 'anonymous' => $this->questionnaire->respondenttype() == 'anonymous', + 'context' => $this->questionnaire->context(), + ]); + $event->trigger(); + + redirect($redirection); + } + + $link = new \moodle_url('/mod/questionnaire/report.php', [ + 'action' => 'vall', + 'sid' => $this->questionnaire->surveyid(), + 'instance' => $instance, + ]); + throw new \moodle_exception('couldnotdelresp', 'mod_questionnaire', $link); + } + + /** + * Return a display string for a respondent (fullname, "unknown", or the empty marker). + * + * @param int $userid + * @return string + */ + private function describe_respondent(int $userid): string { + if (empty($userid)) { + return (string)$userid; + } + if ($user = \core_user::get_user($userid)) { + return fullname($user); + } + return '- ' . get_string('unknown', 'questionnaire') . ' -'; + } +} diff --git a/classes/local/report/report_downloader.php b/classes/local/report/report_downloader.php new file mode 100644 index 000000000..8ff12be85 --- /dev/null +++ b/classes/local/report/report_downloader.php @@ -0,0 +1,325 @@ +. + +namespace mod_questionnaire\local\report; + +use mod_questionnaire\questionnaire; +use mod_questionnaire\submission_notifier; + +/** + * Controller for the staff-side download arms of report.php. + * + * Owns the two CSV / dataformat arms: + * - dwnpg: render the "download options" page (format selector + email options). + * - dfs: execute the download (stream it, email it, or redirect back with an error). + * + * Both methods end with exit() to match the original action-arm behaviour + * (so headers sent by dataformat::download_data are not followed by more + * output). Under PHPUnit, redirect() throws a moodle_exception, which is + * what the tests catch. + * + * @package mod_questionnaire + * @copyright 2026 Mike Churchward (mike.churchward@poetopensource.org) + * @author Mike Churchward + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class report_downloader { + /** + * Constructor. + * + * @param questionnaire $questionnaire The active questionnaire. + * @param \plugin_renderer_base $renderer Plugin renderer used for the options page. + */ + public function __construct( + /** @var questionnaire The active questionnaire. */ + private readonly questionnaire $questionnaire, + /** @var \plugin_renderer_base Plugin renderer for the options page. */ + private readonly \plugin_renderer_base $renderer, + ) { + } + + /** + * Render the "download options" page (dwnpg action arm). + * + * Side effects: sets $PAGE title/heading, echoes header / page / footer, + * triggers all_responses_saved_as_text, and exit()s. + * + * @param object $page Templatable report page. + * @param int $groupmode Activity groupmode (0 = none, 1 = separate, 2 = visible). + * @param int $currentgroupid Active group filter. + * @param array $questionnairegroups Available groups (id => obj) — used to label the selected group. + * @param int $user User filter applied to the download (0 = all users). + * @return void + */ + public function show_download_options( + object $page, + int $groupmode, + int $currentgroupid, + array $questionnairegroups, + int $user + ): void { + global $PAGE; + + require_capability('mod/questionnaire:downloadresponses', $this->questionnaire->context()); + + $course = $this->questionnaire->course(); + $instance = $this->questionnaire->id(); + + $PAGE->set_title(get_string('questionnairereport', 'questionnaire')); + $PAGE->set_heading(format_string($course->fullname)); + echo $this->renderer->header(); + + $tabname = empty($user) ? 'downloadcsv' : 'mydownloadcsv'; + (new \mod_questionnaire\output\tabs($this->questionnaire, $tabname, $currentgroupid))->render($page); + + $groupname = ''; + if ($groupmode > 0) { + if ($currentgroupid == 0) { + $groupname = get_string('allparticipants'); + } else { + $groupname = get_string('membersofselectedgroup', 'group') . ' ' . + get_string('group') . ' ' . $questionnairegroups[$currentgroupid]->name; + } + } + + $output = ''; + $output .= "

\n"; + $output .= \html_writer::tag('h2', get_string('downloadtextformat', 'questionnaire') + . ': ' . get_string('responses', 'questionnaire') . ' ' . + $groupname . $this->renderer->help_icon('downloadtextformat', 'questionnaire')); + $output .= $this->renderer->heading(get_string('textdownloadoptions', 'questionnaire'), 3); + $output .= $this->renderer->box_start(); + $downloadparams = [ + 'instance' => $instance, + 'user' => $user, + 'sid' => $this->questionnaire->surveyid(), + 'action' => 'dfs', + 'group' => $currentgroupid, + ]; + $extrafields = $this->renderer->render_from_template('mod_questionnaire/extrafields', []); + $output .= $this->renderer->download_dataformat_selector( + get_string('downloadtypes', 'questionnaire'), + 'report.php', + 'downloadformat', + $downloadparams, + $extrafields + ); + $output .= $this->renderer->box_end(); + + $page->add_to_page('respondentinfo', $output); + echo $this->renderer->render($page); + + echo $this->renderer->footer('none'); + + $event = \mod_questionnaire\event\all_responses_saved_as_text::create([ + 'objectid' => $instance, + 'context' => $this->questionnaire->context(), + 'courseid' => $this->questionnaire->courseid(), + 'other' => ['action' => 'dwnpg', 'instance' => $instance, 'currentgroupid' => $currentgroupid], + ]); + $event->trigger(); + + exit(); + } + + /** + * Generate and deliver a dataformat export (dfs action arm). + * + * Reads dataformat-related parameters directly from the request, since they + * are tightly coupled to the download form posted from show_download_options(). + * + * Three terminal paths: + * - Streams the file via core\dataformat::download_data (then exit()). + * - Emails the file via save_as_dataformat (then exit()). + * - Redirects back to the options page with an error if email was selected + * but no recipients were specified. + * + * @param object $page Templatable report page (the reporter needs it). + * @param int $currentgroupid Active group filter (echoed in redirect URLs). + * @param int $user User filter (passed through to generate_csv). + * @return void + */ + public function download_responses(object $page, int $currentgroupid, int $user): void { + global $CFG, $USER; + + require_capability('mod/questionnaire:downloadresponses', $this->questionnaire->context()); + + // Use the questionnaire name as the file name. Clean it and change any non-filename characters to '_'. + $name = clean_param($this->questionnaire->name(), PARAM_FILE); + $name = preg_replace('/[^A-Z0-9]+/i', '_', trim($name)); + + $choicecodes = optional_param('choicecodes', '0', PARAM_INT); + $choicetext = optional_param('choicetext', '0', PARAM_INT); + $showincompletes = optional_param('complete', '0', PARAM_INT); + $rankaverages = optional_param('rankaverages', '0', PARAM_INT); + $dataformat = optional_param('downloadformat', '', PARAM_ALPHA); + $emailroles = optional_param('emailroles', 0, PARAM_INT); + $emailextra = optional_param('emailextra', '', PARAM_RAW); + + $output = $this->questionnaire->reporter($this->renderer, $page)->generate_csv( + $currentgroupid, + '', + $user, + $choicecodes, + $choicetext, + $showincompletes, + $rankaverages + ); + + $columns = $output[0]; + unset($output[0]); + + $instance = $this->questionnaire->id(); + $emailreport = optional_param('emailreport', '', PARAM_ALPHA); + if (empty($emailreport)) { + \core\dataformat::download_data($name, $dataformat, $columns, $output); + exit(); + } + + if (get_config('questionnaire', 'allowemailreporting') && (!empty($emailroles) || !empty($emailextra))) { + $users = !empty($emailroles) + ? (new submission_notifier($this->questionnaire))->get_notifiable_users($USER->id) + : []; + $otheremails = explode(',', $emailextra); + if (!empty($users) || !empty($otheremails)) { + $thisurl = new \moodle_url( + 'report.php', + ['instance' => $instance, 'action' => 'dwnpg', 'group' => $currentgroupid] + ); + $this->stream_and_email($name, $dataformat, $columns, $output, $users, $otheremails, $thisurl); + } + exit(); + } + + redirect( + new \moodle_url( + 'report.php', + ['instance' => $instance, 'action' => 'dwnpg', 'group' => $currentgroupid] + ), + get_string('emailsnotspecified', 'questionnaire') + ); + } + + // Private helpers. + + /** + * Render the dataformat output, save it to a temp file, email it to each user / extra + * email address, then echo a redirect message. + * + * Captures \core\dataformat output via an output buffer so the rendered file body can be + * dispatched both as an attachment and as a redirect-message body in the same request. + * Required until \core\dataformat exposes a "render to file" entry point. + * + * @param string $filename Base filename without extension. + * @param string $dataformat Dataformat name ('csv', 'xlsx', etc.). + * @param array $columns Ordered map of column keys and labels. + * @param \Iterator $iterator Iterator over the records (usually a recordset). + * @param array $users Moodle user objects to email. + * @param array $emails Extra email addresses to email. + * @param \moodle_url|string $redirect URL to redirect the browser to once email is dispatched. + */ + private function stream_and_email( + string $filename, + string $dataformat, + array $columns, + \Iterator $iterator, + array $users, + array $emails, + \moodle_url|string $redirect + ): void { + global $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 user's 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 it also makes the browser + // pop a "save / open" dialogue, so we immediately retract the headers with header_remove(). + $format->send_http_headers(); + header_remove(); + + // Start capturing output to write to a file. + ob_start(); + // The pair below 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++); + } + 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'); + $noreplyuser = \core_user::get_noreply_user(); + foreach ($users as $user) { + email_to_user($user, $noreplyuser, $subjecttext, $subjecttext, '', $filepath, $filename . $ext); + } + foreach ($emails as $email) { + $email = trim($email); + $user = (object)[ + 'id' => -10, + 'email' => $email, + 'firstname' => $email, + 'lastname' => $email, + 'firstnamephonetic' => '', + 'lastnamephonetic' => '', + 'middlename' => '', + 'alternatename' => '', + 'mailformat' => 1, + ]; + email_to_user($user, $noreplyuser, $subjecttext, $subjecttext, '', $filepath, $filename . $ext); + } + unlink($filepath); + } + ob_end_clean(); + echo $OUTPUT->redirect_message($redirect, get_string('emailssent', 'questionnaire'), 3, false); + } +} diff --git a/classes/local/report/report_view_builder.php b/classes/local/report/report_view_builder.php new file mode 100644 index 000000000..678a5ee8f --- /dev/null +++ b/classes/local/report/report_view_builder.php @@ -0,0 +1,295 @@ +. + +namespace mod_questionnaire\local\report; + +use mod_questionnaire\local\survey\survey_view_builder; +use mod_questionnaire\questionnaire; + +/** + * Builds the printable and preview report views. + * + * Companion to {@see \mod_questionnaire\local\survey\survey_view_builder} for the + * read-only paths: the print and preview pages (build_print_view) and + * individual response views (render_response). Re-uses survey_view_builder + * for the shared title/respondent header. This class is a builder, not a + * renderer — it populates a templatable page via composition rather than + * extending plugin_renderer_base. + * + * @package mod_questionnaire + * @copyright 2026 onward Mike Churchward (mike.churchward@poetopensource.org) + * @author Mike Churchward + * @license http://www.gnu.org/copyleft/gpl.html GNU Public License + */ +class report_view_builder { + /** + * Constructor. + * + * @param \plugin_renderer_base $renderer The plugin's Moodle renderer. + * @param object $page A templatable page object (previewpage, reportpage, etc.). + */ + public function __construct( + /** @var \plugin_renderer_base The plugin's Moodle renderer. */ + protected readonly \plugin_renderer_base $renderer, + /** @var object A templatable page object (previewpage, reportpage, etc.). */ + protected readonly object $page, + ) { + } + + /** + * Build the print / preview page for the survey. + * + * Delegates to render_response when a specific response id is supplied; + * otherwise renders every question in section order with optional + * preview-mode dependency hints and submit form. + * + * @param questionnaire $q + * @param int $courseid Unused legacy parameter retained for caller compatibility. + * @param string $message Error message displayed at the top of the form. + * @param string $referer 'preview', 'print', or empty. + * @param int $rid Response id to render, or 0 for blank/all. + * @param bool $blankquestionnaire True when rendering a blank questionnaire. + * @return false|void + */ + public function build_print_view( + questionnaire $q, + $courseid, + $message = '', + $referer = '', + $rid = 0, + $blankquestionnaire = false + ) { + global $CFG; + + if (!empty($rid)) { + if (!$q->capabilities()->can_view_response($rid)) { + throw new \moodle_exception('nopermissions', 'mod_questionnaire'); + } + $this->render_response($q, $rid, $referer); + return; + } + + $section = 1; + $questionsbysec = $q->questions_by_section_all(); + $numsections = count($questionsbysec); + + if ($section > $numsections) { + return false; + } + + $hasrequired = $q->survey()->has_required(); + + $i = 1; + for ($j = 2; $j <= $section; $j++) { + $i += count($questionsbysec[$j - 1]); + } + + $action = $CFG->wwwroot . '/mod/questionnaire/preview.php?' . + ($q->coursemodule() ? 'id=' . $q->coursemodule()->id : 'sid=' . $q->surveyid()); + $this->page->add_to_page('formstart', $this->renderer->complete_formstart($action)); + + $formdata = new \stdClass(); + $errors = 1; + if (data_submitted()) { + $formdata = data_submitted(); + $formdata->rid = $formdata->rid ?? 0; + $q->responses()->add_response_from_formdata($formdata); + $pageerror = ''; + $s = 1; + $errors = 0; + foreach ($questionsbysec as $sec) { + $errormessage = $q->responses()->response_check_format($s, $formdata); + if ($errormessage) { + if ($numsections > 1) { + $pageerror = get_string('page', 'questionnaire') . ' ' . $s . ' : '; + } + $this->page->add_to_page( + 'notifications', + $this->renderer->notification( + $pageerror . $errormessage, + \core\output\notification::NOTIFY_ERROR + ) + ); + $errors++; + } + $s++; + } + } + + $this->build_header($q, $message, 1, 1, $hasrequired, ''); + + if (($referer == 'preview') && $q->has_dependencies()) { + $allqdependants = $q->navigator()->get_dependants_and_choices(); + } else { + $allqdependants = []; + } + if ($errors == 0) { + $this->page->add_to_page( + 'message', + $this->renderer->notification( + get_string('submitpreviewcorrect', 'questionnaire'), + \core\output\notification::NOTIFY_SUCCESS + ) + ); + } + + $page = 1; + foreach ($questionsbysec as $sec) { + $output = ''; + if ($numsections > 1) { + $output .= $this->renderer->print_preview_pagenumber( + get_string('page', 'questionnaire') . ' ' . $page + ); + $page++; + } + foreach ($sec as $question) { + if (!$question->is_numbered()) { + $i--; + } + $dependants = $allqdependants[$question->id()] ?? []; + $question->set_isprint($referer === 'print'); + $output .= $this->renderer->question_output( + $question, + ($q->responses()->get_response(0) ?? new \mod_questionnaire\local\response\response()), + $i++, + null, + $dependants, + $q + ); + $this->page->add_to_page('questions', $output); + $output = ''; + } + } + + if ($referer == 'preview' && !$blankquestionnaire) { + $url = $CFG->wwwroot . '/mod/questionnaire/preview.php?' . + ($q->coursemodule() ? 'id=' . $q->coursemodule()->id : 'sid=' . $q->surveyid()); + $this->page->add_to_page( + 'formend', + $this->renderer->print_preview_formend( + $url, + get_string('submitpreview', 'questionnaire'), + get_string('reset') + ) + ); + } + } + + /** + * Render a single response for viewing (report, print, or preview context). + * + * @param questionnaire $q + * @param int $rid + * @param string $referer 'print' suppresses feedback messages. + * @param mixed $resps + * @param bool $compare + * @param bool $isgroupmember + * @param bool $allresponses + * @param int $currentgroupid + * @param string $outputtarget 'html' or 'pdf'. + * @return void + */ + public function render_response( + questionnaire $q, + $rid, + $referer = '', + $resps = '', + $compare = false, + $isgroupmember = false, + $allresponses = false, + $currentgroupid = 0, + $outputtarget = 'html' + ): void { + $this->build_header($q, '', 1, 1, 0, $rid, false, $outputtarget); + + $i = 0; + $q->add_response($rid); + if ($referer != 'print') { + $feedbackmessages = $q->reporter($this->renderer, $this->page)->response_analysis( + $rid, + $resps, + $compare, + $isgroupmember, + $allresponses, + $currentgroupid + ); + if ($feedbackmessages) { + $msgout = ''; + foreach ($feedbackmessages as $msg) { + $msgout .= $msg; + } + $this->page->add_to_page('feedbackmessages', $msgout); + } + + $rendered = $q->feedback()->rendered_notes(); + if ($rendered !== '') { + $this->page->add_to_page( + 'feedbacknotes', + $this->renderer->box(format_text($rendered, FORMAT_HTML)) + ); + } + } + $pdf = ($outputtarget == 'pdf'); + foreach ($q->questions() as $question) { + if (!$question->dependency_fulfilled($rid, $q->questions())) { + continue; + } + if ($question->typeid() < QUESPAGEBREAK) { + $i++; + } + if ($question->typeid() != QUESPAGEBREAK) { + $this->page->add_to_page( + 'responses', + $this->renderer->response_output( + $question, + $q->responses()->get_response($rid), + $i, + $pdf, + $q, + true + ) + ); + } + } + } + + /** + * Delegate the title / respondent / print-blank header to survey_view_builder so + * the two render paths share a single implementation. + * + * @param questionnaire $q + * @param string $message + * @param int $section + * @param int $numsections + * @param bool $hasrequired + * @param int|string $rid + * @param bool $blankquestionnaire + * @param string $outputtarget + */ + private function build_header( + questionnaire $q, + $message, + $section, + $numsections, + $hasrequired, + $rid = '', + $blankquestionnaire = false, + $outputtarget = 'html' + ): void { + (new survey_view_builder($this->renderer, $this->page)) + ->print_survey_start($q, $message, $section, $numsections, $hasrequired, $rid, $blankquestionnaire, $outputtarget); + } +} diff --git a/classes/local/report/report_viewer.php b/classes/local/report/report_viewer.php new file mode 100644 index 000000000..b2f01654c --- /dev/null +++ b/classes/local/report/report_viewer.php @@ -0,0 +1,389 @@ +. + +namespace mod_questionnaire\local\report; + +use mod_questionnaire\output\pdf_factory; +use mod_questionnaire\questionnaire; + +/** + * Controller for the staff-side view arms of report.php. + * + * Owns the two render arms: + * - vall (and the two sort variants vallasort/vallarsort): summary view of all responses. + * - vresp (and the default action): per-respondent view, including PDF/print branches. + * + * Each method handles its own tab/scope setup, group filter UI, response collection, + * and the html / pdf rendering branches. Methods echo the page directly and return void + * (matching the original action arms). + * + * @package mod_questionnaire + * @copyright 2026 Mike Churchward (mike.churchward@poetopensource.org) + * @author Mike Churchward + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class report_viewer { + /** + * Constructor. + * + * @param questionnaire $questionnaire The active questionnaire. + * @param \plugin_renderer_base $renderer Plugin renderer. + */ + public function __construct( + /** @var questionnaire The active questionnaire. */ + private readonly questionnaire $questionnaire, + /** @var \plugin_renderer_base Plugin renderer. */ + private readonly \plugin_renderer_base $renderer, + ) { + } + + /** + * Render the "view all responses" page (vall / vallasort / vallarsort arms). + * + * @param object $page Templatable report page. + * @param string $action One of 'vall', 'vallasort', 'vallarsort'. + * @param string $outputtarget 'html', 'pdf', or 'print'. + * @param \moodle_url $url Base URL for the current page (used by group select and action links). + * @param int $groupmode Activity group mode (0 = none, 1 = separate, 2 = visible). + * @param int $currentgroupid Active group filter id. + * @param array $questionnairegroups Available groups (id => obj). + * @param array $respsallparticipants All-participant responses (pre-fetched). + * @param string $sort 'ascending', 'descending', or 'default'. + * @param string $userview The 'responsestats' filter ('y', '0', or 'n'). + * @param array $responsestatus Map of response-status labels keyed by 'y' / '0' / 'n'. + * @return void + */ + public function view_all_responses( + object $page, + string $action, + string $outputtarget, + \moodle_url $url, + int $groupmode, + int $currentgroupid, + array $questionnairegroups, + array $respsallparticipants, + string $sort, + string $userview, + array $responsestatus + ): void { + global $PAGE; + + $course = $this->questionnaire->course(); + $context = $this->questionnaire->context(); + $cm = $this->questionnaire->coursemodule(); + $instance = $this->questionnaire->id(); + + $PAGE->set_title(get_string('questionnairereport', 'questionnaire')); + $PAGE->set_heading(format_string($course->fullname)); + $canviewallresponses = has_capability('mod/questionnaire:readallresponses', $context); + $canviewallresponsesanytime = has_capability('mod/questionnaire:readallresponseanytime', $context); + if (!$canviewallresponses && !$canviewallresponsesanytime) { + echo $this->renderer->header(); + throw new \moodle_exception('nopermissions', 'mod_questionnaire'); + } + + $currenttab = match ($action) { + 'vallasort' => 'vallasort', + 'vallarsort' => 'vallarsort', + default => 'valldefault', + }; + if ($outputtarget != 'print') { + (new \mod_questionnaire\output\tabs($this->questionnaire, $currenttab, $currentgroupid))->render($page); + } + + $respinfo = ''; + $resps = []; + // Enable choose_group if there are questionnaire groups and groupmode is not set to "no groups" + // and if there are more groups than 1 (or if user can view all groups). + if (is_array($questionnairegroups) && $groupmode > 0) { + $groupselect = groups_print_activity_menu($cm, $url->out(), true); + // Count number of responses in each group. + foreach ($questionnairegroups as $group) { + $respscount = $this->questionnaire->count_submissions(false, $group->id); + $thisgroupname = groups_get_group_name($group->id); + $escapedgroupname = preg_quote($thisgroupname, '/'); + if (!empty($respscount)) { + $groupselect = preg_replace( + '/\', + $groupselect + ); + } else { + $groupselect = preg_replace( + '/\