diff --git a/lang/en/reportbuilder.php b/lang/en/reportbuilder.php new file mode 100644 index 00000000000..1bdb8a76232 --- /dev/null +++ b/lang/en/reportbuilder.php @@ -0,0 +1,67 @@ +. + +/** + * Strings for component 'reportbuilder', language 'en' + * + * @package core_reportbuilder + * @copyright 2020 Sara Arjona + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +$string['actions'] = 'Actions'; +$string['apply'] = 'Apply'; +$string['coursefullnamewithlink'] = 'Course full name with link'; +$string['courseidnumberewithlink'] = 'Course ID number with link'; +$string['courseshortnamewithlink'] = 'Course short name with link'; +$string['customfieldcolumn'] = '{$a}'; +$string['entitycourse'] = 'Course'; +$string['entityuser'] = 'User'; +$string['errorreportaccess'] = 'You can not view this report'; +$string['errorsourceinvalid'] = 'Could not find valid report source'; +$string['errorsourceunavailable'] = 'Report source is not available'; +$string['filtercontains'] = 'Contains'; +$string['filterdatefrom'] = 'Date from'; +$string['filterdateto'] = 'Date to'; +$string['filterdoesnotcontain'] = 'Does not contain'; +$string['filterendswith'] = 'Ends with'; +$string['filterequalorgreaterthan'] = 'Greater than or equal'; +$string['filterequalorlessthan'] = 'Less than or equal'; +$string['filterfieldoperator'] = '{$a} operator'; +$string['filterfieldvalue'] = '{$a} value'; +$string['filtergreaterthan'] = 'Greater than'; +$string['filterinvalid'] = 'Invalid filter'; +$string['filterisanyvalue'] = 'Is any value'; +$string['filterisempty'] = 'Is empty'; +$string['filterisequalto'] = 'Is equal to'; +$string['filterisnotempty'] = 'Is not empty'; +$string['filterisnotequalto'] = 'Is not equal to'; +$string['filterlessthan'] = 'Less than'; +$string['filterrange'] = 'Range'; +$string['filtersapplied'] = 'Filters applied'; +$string['filtersreset'] = 'Filters reset'; +$string['filterstartswith'] = 'Starts with'; +$string['privacy:metadata:preference:reportfilter'] = 'Stored report filter values'; +$string['privacy:metadata:report'] = 'Report definitions'; +$string['privacy:metadata:report:name'] = 'The name of the report'; +$string['privacy:metadata:report:usercreated'] = 'The ID of the user who created the report'; +$string['privacy:metadata:report:usermodified'] = 'The ID of the user who last modified the report'; +$string['resetall'] = 'Reset all'; +$string['selectcourses'] = 'Select courses'; +$string['userfullnamewithlink'] = 'Full name with link'; +$string['userfullnamewithpicture'] = 'Full name with picture'; +$string['userfullnamewithpicturelink'] = 'Full name with picture and link'; +$string['userpicture'] = 'User picture'; diff --git a/lib/components.json b/lib/components.json index ff292383d74..a9a635fd6ef 100644 --- a/lib/components.json +++ b/lib/components.json @@ -104,6 +104,7 @@ "privacy": "privacy", "question": "question", "rating": "rating", + "reportbuilder": "reportbuilder", "repository": "repository", "rss": "rss", "role": "admin\/roles", diff --git a/lib/db/install.xml b/lib/db/install.xml index 4aa9583d222..eeb1af648de 100644 --- a/lib/db/install.xml +++ b/lib/db/install.xml @@ -1,5 +1,5 @@ - @@ -4376,5 +4376,26 @@ + + + + + + + + + + + + + + + + + + + + +
diff --git a/lib/db/services.php b/lib/db/services.php index d796bacb1d3..89b5fe07e61 100644 --- a/lib/db/services.php +++ b/lib/db/services.php @@ -2773,6 +2773,12 @@ $functions = array( 'type' => 'read', 'ajax' => true, ], + 'core_reportbuilder_filters_reset' => [ + 'classname' => 'core_reportbuilder\external\filters\reset', + 'description' => 'Reset filters for given report', + 'type' => 'write', + 'ajax' => true, + ], ); $services = array( diff --git a/lib/db/upgrade.php b/lib/db/upgrade.php index 751081e6e8c..1a537a56bdb 100644 --- a/lib/db/upgrade.php +++ b/lib/db/upgrade.php @@ -2675,5 +2675,37 @@ function xmldb_main_upgrade($oldversion) { upgrade_main_savepoint(true, 2021060900.00); } + if ($oldversion < 2021072800.01) { + // Define table reportbuilder_report to be created. + $table = new xmldb_table('reportbuilder_report'); + + // Adding fields to table reportbuilder_report. + $table->add_field('id', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, XMLDB_SEQUENCE, null); + $table->add_field('source', XMLDB_TYPE_CHAR, '255', null, XMLDB_NOTNULL, null, null); + $table->add_field('type', XMLDB_TYPE_INTEGER, '2', null, XMLDB_NOTNULL, null, '0'); + $table->add_field('contextid', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, null); + $table->add_field('component', XMLDB_TYPE_CHAR, '100', null, XMLDB_NOTNULL, null, null); + $table->add_field('area', XMLDB_TYPE_CHAR, '100', null, XMLDB_NOTNULL, null, null); + $table->add_field('itemid', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, '0'); + $table->add_field('usercreated', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, '0'); + $table->add_field('usermodified', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, '0'); + $table->add_field('timecreated', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, '0'); + $table->add_field('timemodified', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, '0'); + + // Adding keys to table reportbuilder_report. + $table->add_key('primary', XMLDB_KEY_PRIMARY, ['id']); + $table->add_key('usercreated', XMLDB_KEY_FOREIGN, ['usercreated'], 'user', ['id']); + $table->add_key('usermodified', XMLDB_KEY_FOREIGN, ['usermodified'], 'user', ['id']); + $table->add_key('contextid', XMLDB_KEY_FOREIGN, ['contextid'], 'context', ['id']); + + // Conditionally launch create table for reportbuilder_report. + if (!$dbman->table_exists($table)) { + $dbman->create_table($table); + } + + // Main savepoint reached. + upgrade_main_savepoint(true, 2021072800.01); + } + return true; } diff --git a/lib/tests/component_test.php b/lib/tests/component_test.php index f0d570398e5..f9ec91db5fe 100644 --- a/lib/tests/component_test.php +++ b/lib/tests/component_test.php @@ -36,7 +36,7 @@ class component_test extends advanced_testcase { * this is defined here to annoy devs that try to add more without any thinking, * always verify that it does not collide with any existing add-on modules and subplugins!!! */ - const SUBSYSTEMCOUNT = 73; + const SUBSYSTEMCOUNT = 74; public function setUp(): void { $psr0namespaces = new ReflectionProperty('core_component', 'psr0namespaces'); diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 0a152257a9d..55ed5258171 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -202,6 +202,9 @@ contentbank/tests + + reportbuilder/tests + diff --git a/reportbuilder/amd/build/filters.min.js b/reportbuilder/amd/build/filters.min.js new file mode 100644 index 00000000000..de33701836d --- /dev/null +++ b/reportbuilder/amd/build/filters.min.js @@ -0,0 +1,2 @@ +function _typeof(a){"@babel/helpers - typeof";if("function"==typeof Symbol&&"symbol"==typeof Symbol.iterator){_typeof=function(a){return typeof a}}else{_typeof=function(a){return a&&"function"==typeof Symbol&&a.constructor===Symbol&&a!==Symbol.prototype?"symbol":typeof a}}return _typeof(a)}define ("core_reportbuilder/filters",["exports","core/event_dispatcher","core/notification","core/pending","core/str","core/toast","core_form/dynamicform","core_reportbuilder/local/events","core_reportbuilder/local/selectors","core_reportbuilder/local/repository/filters"],function(a,b,c,d,e,f,g,h,i,j){"use strict";Object.defineProperty(a,"__esModule",{value:!0});a.init=void 0;c=m(c);d=m(d);g=m(g);h=l(h);i=l(i);function k(){if("function"!=typeof WeakMap)return null;var a=new WeakMap;k=function(){return a};return a}function l(a){if(a&&a.__esModule){return a}if(null===a||"object"!==_typeof(a)&&"function"!=typeof a){return{default:a}}var b=k();if(b&&b.has(a)){return b.get(a)}var c={},d=Object.defineProperty&&Object.getOwnPropertyDescriptor;for(var e in a){if(Object.prototype.hasOwnProperty.call(a,e)){var f=d?Object.getOwnPropertyDescriptor(a,e):null;if(f&&(f.get||f.set)){Object.defineProperty(c,e,f)}else{c[e]=a[e]}}}c.default=a;if(b){b.set(a,c)}return c}function m(a){return a&&a.__esModule?a:{default:a}}var n=function(a){var k=document.querySelector(i.forSystemReport(a)),l=k.querySelector(i.regions.filtersForm),m=new g.default(l,"\\core_reportbuilder\\form\\filter");m.addEventListener(m.events.FORM_SUBMITTED,function(a){a.preventDefault();(0,b.dispatchEvent)(h.tableReload,{},k);(0,e.get_string)("filtersapplied","core_reportbuilder").then(f.add).catch(c.default.exception)});m.addEventListener(m.events.NOSUBMIT_BUTTON_PRESSED,function(b){b.preventDefault();var g=new d.default("core_reportbuilder/filters:reset");(0,j.reset)(a).then(function(){return(0,e.get_string)("filtersreset","core_reportbuilder")}).then(f.add).then(function(){g.resolve();window.location.reload()}).catch(c.default.exception)});document.querySelector("#region-main").style.overflowX="visible"};a.init=n}); +//# sourceMappingURL=filters.min.js.map diff --git a/reportbuilder/amd/build/filters.min.js.map b/reportbuilder/amd/build/filters.min.js.map new file mode 100644 index 00000000000..b93418bfc43 --- /dev/null +++ b/reportbuilder/amd/build/filters.min.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["../src/filters.js"],"names":["init","reportId","reportElement","document","querySelector","reportSelectors","forSystemReport","filterFormContainer","regions","filtersForm","filterForm","DynamicForm","addEventListener","events","FORM_SUBMITTED","event","preventDefault","reportEvents","tableReload","then","addToast","catch","Notification","exception","NOSUBMIT_BUTTON_PRESSED","pendingPromise","Pending","resolve","window","location","reload","style","overflowX"],"mappings":"8pBAyBA,OACA,OAGA,OACA,OACA,O,ylBAQO,GAAMA,CAAAA,CAAI,CAAG,SAAAC,CAAQ,CAAI,IACtBC,CAAAA,CAAa,CAAGC,QAAQ,CAACC,aAAT,CAAuBC,CAAe,CAACC,eAAhB,CAAgCL,CAAhC,CAAvB,CADM,CAEtBM,CAAmB,CAAGL,CAAa,CAACE,aAAd,CAA4BC,CAAe,CAACG,OAAhB,CAAwBC,WAApD,CAFA,CAGtBC,CAAU,CAAG,GAAIC,UAAJ,CAAgBJ,CAAhB,CAAqC,oCAArC,CAHS,CAM5BG,CAAU,CAACE,gBAAX,CAA4BF,CAAU,CAACG,MAAX,CAAkBC,cAA9C,CAA8D,SAAAC,CAAK,CAAI,CACnEA,CAAK,CAACC,cAAN,GAGA,oBAAcC,CAAY,CAACC,WAA3B,CAAwC,EAAxC,CAA4ChB,CAA5C,EAEA,iBAAU,gBAAV,CAA4B,oBAA5B,EACKiB,IADL,CACUC,KADV,EAEKC,KAFL,CAEWC,UAAaC,SAFxB,CAGH,CATD,EAYAb,CAAU,CAACE,gBAAX,CAA4BF,CAAU,CAACG,MAAX,CAAkBW,uBAA9C,CAAuE,SAAAT,CAAK,CAAI,CAC5EA,CAAK,CAACC,cAAN,GAEA,GAAMS,CAAAA,CAAc,CAAG,GAAIC,UAAJ,CAAY,kCAAZ,CAAvB,CAEA,YAAazB,CAAb,EACKkB,IADL,CACU,iBAAM,iBAAU,cAAV,CAA0B,oBAA1B,CAAN,CADV,EAEKA,IAFL,CAEUC,KAFV,EAGKD,IAHL,CAGU,UAAM,CACRM,CAAc,CAACE,OAAf,GACAC,MAAM,CAACC,QAAP,CAAgBC,MAAhB,EAEH,CAPL,EAQKT,KARL,CAQWC,UAAaC,SARxB,CASH,CAdD,EAiBApB,QAAQ,CAACC,aAAT,CAAuB,cAAvB,EAAuC2B,KAAvC,CAA6CC,SAA7C,CAAyD,SAC5D,CApCM,C","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 * Report builder filter management\n *\n * @module core_reportbuilder/filters\n * @package core_reportbuilder\n * @copyright 2021 Paul Holden \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\nimport {dispatchEvent} from 'core/event_dispatcher';\nimport Notification from 'core/notification';\nimport Pending from 'core/pending';\nimport {get_string as getString} from 'core/str';\nimport {add as addToast} from 'core/toast';\nimport DynamicForm from 'core_form/dynamicform';\nimport * as reportEvents from 'core_reportbuilder/local/events';\nimport * as reportSelectors from 'core_reportbuilder/local/selectors';\nimport {reset as resetFilters} from 'core_reportbuilder/local/repository/filters';\n\n/**\n * Initialise module\n *\n * @param {Number} reportId\n */\nexport const init = reportId => {\n const reportElement = document.querySelector(reportSelectors.forSystemReport(reportId));\n const filterFormContainer = reportElement.querySelector(reportSelectors.regions.filtersForm);\n const filterForm = new DynamicForm(filterFormContainer, '\\\\core_reportbuilder\\\\form\\\\filter');\n\n // Submit report filters.\n filterForm.addEventListener(filterForm.events.FORM_SUBMITTED, event => {\n event.preventDefault();\n\n // After the form has been submitted, we should trigger report table reload.\n dispatchEvent(reportEvents.tableReload, {}, reportElement);\n\n getString('filtersapplied', 'core_reportbuilder')\n .then(addToast)\n .catch(Notification.exception);\n });\n\n // Reset report filters.\n filterForm.addEventListener(filterForm.events.NOSUBMIT_BUTTON_PRESSED, event => {\n event.preventDefault();\n\n const pendingPromise = new Pending('core_reportbuilder/filters:reset');\n\n resetFilters(reportId)\n .then(() => getString('filtersreset', 'core_reportbuilder'))\n .then(addToast)\n .then(() => {\n pendingPromise.resolve();\n window.location.reload();\n return;\n })\n .catch(Notification.exception);\n });\n\n // Modify \"region-main\" overflow for big filter forms.\n document.querySelector('#region-main').style.overflowX = \"visible\";\n};\n"],"file":"filters.min.js"} \ No newline at end of file diff --git a/reportbuilder/amd/build/local/events.min.js b/reportbuilder/amd/build/local/events.min.js new file mode 100644 index 00000000000..6a67e2031ef --- /dev/null +++ b/reportbuilder/amd/build/local/events.min.js @@ -0,0 +1,2 @@ +define ("core_reportbuilder/local/events",["exports"],function(a){"use strict";Object.defineProperty(a,"__esModule",{value:!0});a.default=void 0;a.default={tableReload:"core_reportbuilder_table_reload"};return a.default}); +//# sourceMappingURL=events.min.js.map diff --git a/reportbuilder/amd/build/local/events.min.js.map b/reportbuilder/amd/build/local/events.min.js.map new file mode 100644 index 00000000000..18d9e47834d --- /dev/null +++ b/reportbuilder/amd/build/local/events.min.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["../../src/local/events.js"],"names":["tableReload"],"mappings":"2JAwBe,CAGXA,WAAW,CAAE,iCAHF,C","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 * Report builder events\n *\n * @module core_reportbuilder/local/events\n * @package core_reportbuilder\n * @copyright 2021 Paul Holden \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\nexport default {\n // Trigger table reloading, passing optional `preservePagination` details argument can be used to\n // define whether current pagination should be preserved (default is false, i.e. reload first page.)\n tableReload: 'core_reportbuilder_table_reload',\n};\n"],"file":"events.min.js"} \ No newline at end of file diff --git a/reportbuilder/amd/build/local/repository/filters.min.js b/reportbuilder/amd/build/local/repository/filters.min.js new file mode 100644 index 00000000000..c68a07eddac --- /dev/null +++ b/reportbuilder/amd/build/local/repository/filters.min.js @@ -0,0 +1,2 @@ +define ("core_reportbuilder/local/repository/filters",["exports","core/ajax"],function(a,b){"use strict";Object.defineProperty(a,"__esModule",{value:!0});a.reset=void 0;b=function(a){return a&&a.__esModule?a:{default:a}}(b);var c=function(a){return b.default.call([{methodname:"core_reportbuilder_filters_reset",args:{reportid:a}}])[0]};a.reset=c}); +//# sourceMappingURL=filters.min.js.map diff --git a/reportbuilder/amd/build/local/repository/filters.min.js.map b/reportbuilder/amd/build/local/repository/filters.min.js.map new file mode 100644 index 00000000000..e6f4912e6e1 --- /dev/null +++ b/reportbuilder/amd/build/local/repository/filters.min.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["../../../src/local/repository/filters.js"],"names":["reset","reportId","Ajax","call","methodname","args","reportid"],"mappings":"yKAwBA,uDAQO,GAAMA,CAAAA,CAAK,CAAG,SAAAC,CAAQ,CAAI,CAM7B,MAAOC,WAAKC,IAAL,CAAU,CALD,CACZC,UAAU,CAAE,kCADA,CAEZC,IAAI,CAAE,CAACC,QAAQ,CAAEL,CAAX,CAFM,CAKC,CAAV,EAAqB,CAArB,CACV,CAPM,C","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 * Module to handle filter AJAX requests\n *\n * @module core_reportbuilder/local/repository/filters\n * @package core_reportbuilder\n * @copyright 2021 Paul Holden \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\nimport Ajax from 'core/ajax';\n\n/**\n * Reset all filters for given report\n *\n * @param {Number} reportId\n * @return {Promise}\n */\nexport const reset = reportId => {\n const request = {\n methodname: 'core_reportbuilder_filters_reset',\n args: {reportid: reportId}\n };\n\n return Ajax.call([request])[0];\n};\n"],"file":"filters.min.js"} \ No newline at end of file diff --git a/reportbuilder/amd/build/local/selectors.min.js b/reportbuilder/amd/build/local/selectors.min.js new file mode 100644 index 00000000000..91e9a6aa3bf --- /dev/null +++ b/reportbuilder/amd/build/local/selectors.min.js @@ -0,0 +1,2 @@ +define ("core_reportbuilder/local/selectors",["exports"],function(a){"use strict";Object.defineProperty(a,"__esModule",{value:!0});a.default=void 0;var b={regions:{systemReport:"[data-region=\"core_reportbuilder/system-report\"]",filtersForm:"[data-region=\"filters-form\"]"}};b.forSystemReport=function(a){return"".concat(b.regions.systemReport,"[data-reportid=\"").concat(a,"\"]")};a.default=b;return a.default}); +//# sourceMappingURL=selectors.min.js.map diff --git a/reportbuilder/amd/build/local/selectors.min.js.map b/reportbuilder/amd/build/local/selectors.min.js.map new file mode 100644 index 00000000000..837b0d11c4e --- /dev/null +++ b/reportbuilder/amd/build/local/selectors.min.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["../../src/local/selectors.js"],"names":["SELECTORS","regions","systemReport","filtersForm","forSystemReport","reportId"],"mappings":"oJAwBA,GAAMA,CAAAA,CAAS,CAAG,CACdC,OAAO,CAAE,CACLC,YAAY,CAAE,oDADT,CAELC,WAAW,CAAE,gCAFR,CADK,CAAlB,CAOAH,CAAS,CAACI,eAAV,CAA4B,SAAAC,CAAQ,kBAAOL,CAAS,CAACC,OAAV,CAAkBC,YAAzB,6BAAwDG,CAAxD,QAApC,C,UAEeL,C","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 * Report builder selectors\n *\n * @module core_reportbuilder/local/selectors\n * @package core_reportbuilder\n * @copyright 2021 Paul Holden \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\nconst SELECTORS = {\n regions: {\n systemReport: '[data-region=\"core_reportbuilder/system-report\"]',\n filtersForm: '[data-region=\"filters-form\"]',\n },\n};\n\nSELECTORS.forSystemReport = reportId => `${SELECTORS.regions.systemReport}[data-reportid=\"${reportId}\"]`;\n\nexport default SELECTORS;\n"],"file":"selectors.min.js"} \ No newline at end of file diff --git a/reportbuilder/amd/build/report.min.js b/reportbuilder/amd/build/report.min.js new file mode 100644 index 00000000000..3b80c88f006 --- /dev/null +++ b/reportbuilder/amd/build/report.min.js @@ -0,0 +1,2 @@ +function _typeof(a){"@babel/helpers - typeof";if("function"==typeof Symbol&&"symbol"==typeof Symbol.iterator){_typeof=function(a){return typeof a}}else{_typeof=function(a){return a&&"function"==typeof Symbol&&a.constructor===Symbol&&a!==Symbol.prototype?"symbol":typeof a}}return _typeof(a)}define ("core_reportbuilder/report",["exports","core_reportbuilder/local/events","core_reportbuilder/local/selectors","core_table/dynamic","core_table/local/dynamic/selectors"],function(a,b,c,d,e){"use strict";Object.defineProperty(a,"__esModule",{value:!0});a.init=void 0;b=g(b);c=g(c);e=g(e);function f(){if("function"!=typeof WeakMap)return null;var a=new WeakMap;f=function(){return a};return a}function g(a){if(a&&a.__esModule){return a}if(null===a||"object"!==_typeof(a)&&"function"!=typeof a){return{default:a}}var b=f();if(b&&b.has(a)){return b.get(a)}var c={},d=Object.defineProperty&&Object.getOwnPropertyDescriptor;for(var e in a){if(Object.prototype.hasOwnProperty.call(a,e)){var g=d?Object.getOwnPropertyDescriptor(a,e):null;if(g&&(g.get||g.set)){Object.defineProperty(c,e,g)}else{c[e]=a[e]}}}c.default=a;if(b){b.set(a,c)}return c}function h(a,b,c,d,e,f,g){try{var h=a[f](g),i=h.value}catch(a){c(a);return}if(h.done){b(i)}else{Promise.resolve(i).then(d,e)}}function i(a){return function(){var b=this,c=arguments;return new Promise(function(d,e){var i=a.apply(b,c);function f(a){h(i,d,e,f,g,"next",a)}function g(a){h(i,d,e,f,g,"throw",a)}f(void 0)})}}var j=function(a){document.addEventListener(b.tableReload,function(){var b=i(regeneratorRuntime.mark(function b(f){var g,h,i,j;return regeneratorRuntime.wrap(function(b){while(1){switch(b.prev=b.next){case 0:h=f.target.closest(c.forSystemReport(a));if(!(null===h)){b.next=3;break}return b.abrupt("return");case 3:i=h.querySelector(e.main.region);j=(null===(g=f.detail)||void 0===g?void 0:g.preservePagination)?null:1;b.next=7;return(0,d.setPageNumber)(i,j,!1).then(d.refreshTableContent);case 7:case"end":return b.stop();}}},b)}));return function(){return b.apply(this,arguments)}}())};a.init=j}); +//# sourceMappingURL=report.min.js.map diff --git a/reportbuilder/amd/build/report.min.js.map b/reportbuilder/amd/build/report.min.js.map new file mode 100644 index 00000000000..43ae097ee25 --- /dev/null +++ b/reportbuilder/amd/build/report.min.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["../src/report.js"],"names":["init","reportId","document","addEventListener","reportEvents","tableReload","event","triggerElement","target","closest","reportSelectors","forSystemReport","tableRoot","querySelector","tableSelectors","main","region","pageNumber","detail","preservePagination","then","refreshTableContent"],"mappings":"ojBAwBA,OACA,OAEA,O,q2BAOO,GAAMA,CAAAA,CAAI,CAAG,SAAAC,CAAQ,CAAI,CAE5BC,QAAQ,CAACC,gBAAT,CAA0BC,CAAY,CAACC,WAAvC,4CAAoD,WAAMC,CAAN,+FAC1CC,CAD0C,CACzBD,CAAK,CAACE,MAAN,CAAaC,OAAb,CAAqBC,CAAe,CAACC,eAAhB,CAAgCV,CAAhC,CAArB,CADyB,MAEzB,IAAnB,GAAAM,CAF4C,mDAM1CK,CAN0C,CAM9BL,CAAc,CAACM,aAAf,CAA6BC,CAAc,CAACC,IAAf,CAAoBC,MAAjD,CAN8B,CAO1CC,CAP0C,CAO7B,WAAAX,CAAK,CAACY,MAAN,uBAAcC,kBAAd,EAAmC,IAAnC,CAA0C,CAPb,gBAS1C,oBAAcP,CAAd,CAAyBK,CAAzB,KACDG,IADC,CACIC,qBADJ,CAT0C,yCAApD,wDAYH,CAdM,C","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 * Report builder report management\n *\n * @module core_reportbuilder/report\n * @package core_reportbuilder\n * @copyright 2021 Paul Holden \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\nimport * as reportEvents from 'core_reportbuilder/local/events';\nimport * as reportSelectors from 'core_reportbuilder/local/selectors';\nimport {setPageNumber, refreshTableContent} from 'core_table/dynamic';\nimport * as tableSelectors from 'core_table/local/dynamic/selectors';\n\n/**\n * Initialise module\n *\n * @param {Number} reportId\n */\nexport const init = reportId => {\n // Listen for the table reload event.\n document.addEventListener(reportEvents.tableReload, async(event) => {\n const triggerElement = event.target.closest(reportSelectors.forSystemReport(reportId));\n if (triggerElement === null) {\n return;\n }\n\n const tableRoot = triggerElement.querySelector(tableSelectors.main.region);\n const pageNumber = event.detail?.preservePagination ? null : 1;\n\n await setPageNumber(tableRoot, pageNumber, false)\n .then(refreshTableContent);\n });\n};\n"],"file":"report.min.js"} \ No newline at end of file diff --git a/reportbuilder/amd/src/filters.js b/reportbuilder/amd/src/filters.js new file mode 100644 index 00000000000..5d7696b87e0 --- /dev/null +++ b/reportbuilder/amd/src/filters.js @@ -0,0 +1,76 @@ +// 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 . + +/** + * Report builder filter management + * + * @module core_reportbuilder/filters + * @package core_reportbuilder + * @copyright 2021 Paul Holden + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +import {dispatchEvent} from 'core/event_dispatcher'; +import Notification from 'core/notification'; +import Pending from 'core/pending'; +import {get_string as getString} from 'core/str'; +import {add as addToast} from 'core/toast'; +import DynamicForm from 'core_form/dynamicform'; +import * as reportEvents from 'core_reportbuilder/local/events'; +import * as reportSelectors from 'core_reportbuilder/local/selectors'; +import {reset as resetFilters} from 'core_reportbuilder/local/repository/filters'; + +/** + * Initialise module + * + * @param {Number} reportId + */ +export const init = reportId => { + const reportElement = document.querySelector(reportSelectors.forSystemReport(reportId)); + const filterFormContainer = reportElement.querySelector(reportSelectors.regions.filtersForm); + const filterForm = new DynamicForm(filterFormContainer, '\\core_reportbuilder\\form\\filter'); + + // Submit report filters. + filterForm.addEventListener(filterForm.events.FORM_SUBMITTED, event => { + event.preventDefault(); + + // After the form has been submitted, we should trigger report table reload. + dispatchEvent(reportEvents.tableReload, {}, reportElement); + + getString('filtersapplied', 'core_reportbuilder') + .then(addToast) + .catch(Notification.exception); + }); + + // Reset report filters. + filterForm.addEventListener(filterForm.events.NOSUBMIT_BUTTON_PRESSED, event => { + event.preventDefault(); + + const pendingPromise = new Pending('core_reportbuilder/filters:reset'); + + resetFilters(reportId) + .then(() => getString('filtersreset', 'core_reportbuilder')) + .then(addToast) + .then(() => { + pendingPromise.resolve(); + window.location.reload(); + return; + }) + .catch(Notification.exception); + }); + + // Modify "region-main" overflow for big filter forms. + document.querySelector('#region-main').style.overflowX = "visible"; +}; diff --git a/reportbuilder/amd/src/local/events.js b/reportbuilder/amd/src/local/events.js new file mode 100644 index 00000000000..7d76be107b9 --- /dev/null +++ b/reportbuilder/amd/src/local/events.js @@ -0,0 +1,29 @@ +// 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 . + +/** + * Report builder events + * + * @module core_reportbuilder/local/events + * @package core_reportbuilder + * @copyright 2021 Paul Holden + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +export default { + // Trigger table reloading, passing optional `preservePagination` details argument can be used to + // define whether current pagination should be preserved (default is false, i.e. reload first page.) + tableReload: 'core_reportbuilder_table_reload', +}; diff --git a/reportbuilder/amd/src/local/repository/filters.js b/reportbuilder/amd/src/local/repository/filters.js new file mode 100644 index 00000000000..97e033bef9d --- /dev/null +++ b/reportbuilder/amd/src/local/repository/filters.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 . + +/** + * Module to handle filter AJAX requests + * + * @module core_reportbuilder/local/repository/filters + * @package core_reportbuilder + * @copyright 2021 Paul Holden + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +import Ajax from 'core/ajax'; + +/** + * Reset all filters for given report + * + * @param {Number} reportId + * @return {Promise} + */ +export const reset = reportId => { + const request = { + methodname: 'core_reportbuilder_filters_reset', + args: {reportid: reportId} + }; + + return Ajax.call([request])[0]; +}; diff --git a/reportbuilder/amd/src/local/selectors.js b/reportbuilder/amd/src/local/selectors.js new file mode 100644 index 00000000000..7c2f13b75a8 --- /dev/null +++ b/reportbuilder/amd/src/local/selectors.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 . + +/** + * Report builder selectors + * + * @module core_reportbuilder/local/selectors + * @package core_reportbuilder + * @copyright 2021 Paul Holden + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +const SELECTORS = { + regions: { + systemReport: '[data-region="core_reportbuilder/system-report"]', + filtersForm: '[data-region="filters-form"]', + }, +}; + +SELECTORS.forSystemReport = reportId => `${SELECTORS.regions.systemReport}[data-reportid="${reportId}"]`; + +export default SELECTORS; diff --git a/reportbuilder/amd/src/report.js b/reportbuilder/amd/src/report.js new file mode 100644 index 00000000000..681c1ed6fa9 --- /dev/null +++ b/reportbuilder/amd/src/report.js @@ -0,0 +1,49 @@ +// 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 . + +/** + * Report builder report management + * + * @module core_reportbuilder/report + * @package core_reportbuilder + * @copyright 2021 Paul Holden + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +import * as reportEvents from 'core_reportbuilder/local/events'; +import * as reportSelectors from 'core_reportbuilder/local/selectors'; +import {setPageNumber, refreshTableContent} from 'core_table/dynamic'; +import * as tableSelectors from 'core_table/local/dynamic/selectors'; + +/** + * Initialise module + * + * @param {Number} reportId + */ +export const init = reportId => { + // Listen for the table reload event. + document.addEventListener(reportEvents.tableReload, async(event) => { + const triggerElement = event.target.closest(reportSelectors.forSystemReport(reportId)); + if (triggerElement === null) { + return; + } + + const tableRoot = triggerElement.querySelector(tableSelectors.main.region); + const pageNumber = event.detail?.preservePagination ? null : 1; + + await setPageNumber(tableRoot, pageNumber, false) + .then(refreshTableContent); + }); +}; diff --git a/reportbuilder/classes/external/filters/reset.php b/reportbuilder/classes/external/filters/reset.php new file mode 100644 index 00000000000..2dc22d1b4aa --- /dev/null +++ b/reportbuilder/classes/external/filters/reset.php @@ -0,0 +1,79 @@ +. + +declare(strict_types=1); + +namespace core_reportbuilder\external\filters; + +use context_system; +use external_api; +use external_function_parameters; +use external_value; +use core_reportbuilder\local\helpers\user_filter_manager; + +defined('MOODLE_INTERNAL') || die(); + +global $CFG; +require_once("{$CFG->libdir}/externallib.php"); + +/** + * External method for resetting report filters + * + * @package core_reportbuilder + * @copyright 2021 Paul Holden + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class reset extends external_api { + + /** + * External method parameters + * + * @return external_function_parameters + */ + public static function execute_parameters(): external_function_parameters { + return new external_function_parameters([ + 'reportid' => new external_value(PARAM_INT, 'Report ID'), + ]); + } + + /** + * External method execution + * + * @param int $reportid + * @return bool + */ + public static function execute(int $reportid): bool { + [ + 'reportid' => $reportid, + ] = self::validate_parameters(self::execute_parameters(), [ + 'reportid' => $reportid, + ]); + + $context = context_system::instance(); + self::validate_context($context); + + return user_filter_manager::reset_all($reportid); + } + + /** + * External method return value + * + * @return external_value + */ + public static function execute_returns(): external_value { + return new external_value(PARAM_BOOL, 'Success'); + } +} diff --git a/reportbuilder/classes/external/system_report_exporter.php b/reportbuilder/classes/external/system_report_exporter.php new file mode 100644 index 00000000000..3f77f92caff --- /dev/null +++ b/reportbuilder/classes/external/system_report_exporter.php @@ -0,0 +1,115 @@ +. + +declare(strict_types=1); + +namespace core_reportbuilder\external; + +use core\external\persistent_exporter; +use core_reportbuilder\form\filter; +use core_reportbuilder\local\models\report; +use core_reportbuilder\local\report\base; +use core_reportbuilder\table\system_report_table; +use core_reportbuilder\table\system_report_table_filterset; +use core_reportbuilder\table\system_report_table_parameters_filter; +use renderer_base; + +/** + * Report exporter class + * + * @package core_reportbuilder + * @copyright 2020 Paul Holden + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class system_report_exporter extends persistent_exporter { + + /** + * Return the name of the class we are exporting + * + * @return string + */ + protected static function define_class(): string { + return report::class; + } + + /** + * Return a list of objects that are related to the persistent + * + * @return array + */ + protected static function define_related(): array { + return [ + 'source' => base::class, + 'parameters' => 'string', + ]; + } + + /** + * Return a list of additional properties used only for display + * + * @return array + */ + protected static function define_other_properties(): array { + return [ + 'table' => ['type' => PARAM_RAW], + 'parameters' => ['type' => PARAM_RAW], + 'filterspresent' => ['type' => PARAM_BOOL], + 'filtersform' => [ + 'type' => PARAM_RAW, + 'optional' => true, + ], + ]; + } + + /** + * Get additional values to inject while exporting + * + * @uses \core_reportbuilder\output\renderer::render_system_report_table() + * + * @param renderer_base $output + * @return array + */ + protected function get_other_values(renderer_base $output): array { + /** @var base $source */ + $source = $this->related['source']; + + /** @var array $parameters */ + $parameters = $this->related['parameters']; + + $filterset = new system_report_table_filterset(); + $filterset->add_filter(new system_report_table_parameters_filter('parameters', null, [$parameters])); + + $table = system_report_table::create($this->persistent->get('id'), (array) json_decode($parameters, true)); + $table->set_filterset($filterset); + + // Generate filters form if report contains any filters. + $filterspresent = !empty($source->get_filters()); + if ($filterspresent) { + $filtersform = new filter(null, null, 'post', '', [], true, [ + 'reportid' => $this->persistent->get('id'), + 'parameters' => $parameters, + ]); + $filtersform->set_data_for_dynamic_submission(); + } + + return [ + 'table' => $output->render($table), + 'parameters' => $this->related['parameters'], + 'filterspresent' => $filterspresent, + 'filtersform' => $filterspresent ? $filtersform->render() : '', + ]; + } +} diff --git a/reportbuilder/classes/form/filter.php b/reportbuilder/classes/form/filter.php new file mode 100644 index 00000000000..54f43fb7b78 --- /dev/null +++ b/reportbuilder/classes/form/filter.php @@ -0,0 +1,144 @@ +. + +declare(strict_types=1); + +namespace core_reportbuilder\form; + +use context; +use moodle_url; +use core_form\dynamic_form; +use core_reportbuilder\manager; +use core_reportbuilder\system_report; +use core_reportbuilder\local\models\report; + +/** + * Dynamic filter form + * + * @package core_reportbuilder + * @copyright 2021 Paul Holden + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class filter extends dynamic_form { + + /** + * Return instance of the system report using the filter form + * + * @return system_report + */ + private function get_system_report(): system_report { + $report = new report($this->optional_param('reportid', 0, PARAM_INT)); + $parameters = (array) json_decode($this->optional_param('parameters', '', PARAM_RAW)); + + /** @var system_report $systemreport */ + $systemreport = manager::get_report_from_persistent($report, $parameters); + + return $systemreport; + } + + /** + * Return the context for the form, it should be that of the system report itself + * + * @return context + */ + protected function get_context_for_dynamic_submission(): context { + return ($this->get_system_report())->get_context(); + } + + /** + * Ensure current user is able to use this form + * + * A {@see \core_reportbuilder\report_access_exception} will be thrown if they can't + */ + protected function check_access_for_dynamic_submission(): void { + $this->get_system_report()->require_can_view(); + } + + /** + * Process the form submission + * + * @return bool + */ + public function process_dynamic_submission() { + $values = $this->get_data(); + + // Remove some unneeded fields. + unset($values->reportid, $values->parameters); + + return $this->get_system_report()->set_filter_values((array) $values); + } + + /** + * Load in existing data as form defaults + */ + public function set_data_for_dynamic_submission(): void { + $defaults = [ + 'reportid' => $this->optional_param('reportid', 0, PARAM_INT), + 'parameters' => $this->optional_param('parameters', 0, PARAM_RAW), + ]; + + $this->set_data(array_merge($defaults, $this->get_system_report()->get_filter_values())); + } + + /** + * URL of the page using this form + * + * @return moodle_url + */ + protected function get_page_url_for_dynamic_submission(): moodle_url { + return new moodle_url('/'); + } + + /** + * Filter form definition. It should provide necessary field itself, then allow all report filters to add their own elements + */ + protected function definition() { + global $OUTPUT; + + $mform = $this->_form; + + $mform->addElement('hidden', 'reportid'); + $mform->setType('reportid', PARAM_INT); + + $mform->addElement('hidden', 'parameters'); + $mform->setType('parameters', PARAM_RAW); + + // Allow each filter instance to add itself to this form, wrapping each inside custom header/footer template. + foreach ($this->get_system_report()->get_filter_instances() as $filterinstance) { + $mform->addElement('html', $OUTPUT->render_from_template('core_reportbuilder/local/filters/header', [ + 'name' => $filterinstance->get_header(), + ])); + + $filterinstance->setup_form($mform); + $mform->addElement('html', $OUTPUT->render_from_template('core_reportbuilder/local/filters/footer', [])); + } + + $this->set_display_vertical(); + + // We'll add a second submit button to the form that will be used to reset current report filters. + $mform->registerNoSubmitButton('resetfilters'); + + $buttons = []; + $buttons[] = $mform->createElement('submit', 'submitbutton', get_string('apply', 'core_reportbuilder')); + $buttons[] = $mform->createElement('submit', 'resetfilters', get_string('resetall', 'core_reportbuilder'), + null, null, ['customclassoverride' => 'btn-link']); + + $mform->addGroup($buttons, 'buttonar', '', [' '], false); + $mform->closeHeaderBefore('buttonar'); + + $mform->disable_form_change_checker(); + } +} diff --git a/reportbuilder/classes/local/entities/base.php b/reportbuilder/classes/local/entities/base.php new file mode 100644 index 00000000000..a91ed7b2a96 --- /dev/null +++ b/reportbuilder/classes/local/entities/base.php @@ -0,0 +1,273 @@ +. + +declare(strict_types=1); + +namespace core_reportbuilder\local\entities; + +use coding_exception; +use lang_string; +use core_reportbuilder\local\report\column; +use core_reportbuilder\local\report\filter; + +/** + * Base class for all report entities + * + * @package core_reportbuilder + * @copyright 2019 Marina Glancy + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +abstract class base { + + /** @var string $entityname Internal reference to name of entity */ + private $entityname = ''; + + /** @var lang_string $entitytitle Used as a title for the entity in reports */ + private $entitytitle = null; + + /** @var array $tablealiases Database tables that this entity uses and their default aliases */ + private $tablealiases = []; + + /** @var string[] $joins List of SQL joins for the entity */ + private $joins = []; + + /** @var column[] $columns List of columns for the entity */ + private $columns = []; + + /** @var filter[] $filters List of filters for the entity */ + private $filters = []; + + /** + * Database tables that this entity uses and their default aliases + * + * Must be overridden by the entity to list all database tables that it expects to be present in the main + * SQL or in JOINs added to this entity + * + * @return string[] Array of $tablename => $alias + */ + abstract protected function get_default_table_aliases(): array; + + /** + * The default title for this entity + * + * @return lang_string + */ + abstract protected function get_default_entity_title(): lang_string; + + /** + * Initialise the entity, called automatically when it is added to a report + * + * This is where entity defines all its columns and filters by calling: + * - {@see add_column} + * - {@see add_filter} + * - etc + * + * @return self + */ + abstract public function initialise(): self; + + /** + * The default machine-readable name for this entity that will be used in the internal names of the columns/filters + * + * @return string + */ + protected function get_default_entity_name(): string { + $namespace = explode('\\', get_called_class()); + + return end($namespace); + } + + /** + * Set entity name + * + * @param string $entityname + * @return self + * @throws coding_exception + */ + final public function set_entity_name(string $entityname): self { + if ($entityname === '' || $entityname !== clean_param($entityname, PARAM_ALPHANUMEXT)) { + throw new coding_exception('Entity name must be comprised of alphanumeric character, underscore or dash'); + } + + $this->entityname = $entityname; + return $this; + } + + /** + * Return entity name + * + * @return string + */ + final public function get_entity_name(): string { + return $this->entityname ?: $this->get_default_entity_name(); + } + + /** + * Set entity title + * + * @param lang_string $title + * @return self + */ + final public function set_entity_title(lang_string $title): self { + $this->entitytitle = $title; + return $this; + } + + /** + * Get entity title + * + * @return lang_string + */ + final public function get_entity_title(): lang_string { + return $this->entitytitle ?? $this->get_default_entity_title(); + } + + /** + * Override the default alias for given database table used in entity queries + * + * @param string $tablename + * @param string $alias + * @return self + * @throws coding_exception + */ + final public function set_table_alias(string $tablename, string $alias): self { + if (!array_key_exists($tablename, $this->get_default_table_aliases())) { + throw new coding_exception('Invalid table name', $tablename); + } + + $this->tablealiases[$tablename] = $alias; + return $this; + } + + /** + * Returns an alias used in the queries for a given table + * + * @param string $tablename + * @return string + * @throws coding_exception + */ + final public function get_table_alias(string $tablename): string { + $defaulttablealiases = $this->get_default_table_aliases(); + if (!array_key_exists($tablename, $defaulttablealiases)) { + throw new coding_exception('Invalid table name', $tablename); + } + + return $this->tablealiases[$tablename] ?? $defaulttablealiases[$tablename]; + } + + /** + * Add join clause required for this entity to join to existing tables/entities + * + * @param string $join + * @return self + */ + final public function add_join(string $join): self { + $this->joins[trim($join)] = trim($join); + return $this; + } + + /** + * Add multiple join clauses required for this entity {@see add_join} + * + * @param string[] $joins + * @return self + */ + final public function add_joins(array $joins): self { + foreach ($joins as $join) { + $this->add_join($join); + } + return $this; + } + + /** + * Return entity joins + * + * @return string[] + */ + final protected function get_joins(): array { + return array_values($this->joins); + } + + /** + * Add a column to the entity + * + * @param column $column + * @return self + */ + final protected function add_column(column $column): self { + $this->columns[$column->get_name()] = $column; + return $this; + } + + /** + * Returns entity columns + * + * @return column[] + */ + final public function get_columns(): array { + return $this->columns; + } + + /** + * Returns an entity column + * + * @param string $name + * @return column + * @throws coding_exception For invalid column name + */ + final public function get_column(string $name): column { + if (!array_key_exists($name, $this->columns)) { + throw new coding_exception('Invalid column name', $name); + } + + return $this->columns[$name]; + } + + /** + * Add a filter to the entity + * + * @param filter $filter + * @return $this + */ + final protected function add_filter(filter $filter): self { + $this->filters[$filter->get_name()] = $filter; + return $this; + } + + /** + * Returns entity filters + * + * @return filter[] + */ + final public function get_filters(): array { + return $this->filters; + } + + /** + * Returns an entity filter + * + * @param string $name + * @return filter + * @throws coding_exception For invalid filter name + */ + final public function get_filter(string $name): filter { + if (!array_key_exists($name, $this->filters)) { + throw new coding_exception('Invalid filter name', $name); + } + + return $this->filters[$name]; + } +} diff --git a/reportbuilder/classes/local/entities/course.php b/reportbuilder/classes/local/entities/course.php new file mode 100644 index 00000000000..dd0989f2f79 --- /dev/null +++ b/reportbuilder/classes/local/entities/course.php @@ -0,0 +1,464 @@ +. + +declare(strict_types=1); + +namespace core_reportbuilder\local\entities; + +use context_course; +use context_helper; +use core_course_category; +use core_reportbuilder\local\filters\boolean_select; +use core_reportbuilder\local\filters\course_selector; +use core_reportbuilder\local\filters\date; +use core_reportbuilder\local\filters\select; +use core_reportbuilder\local\filters\text; +use core_reportbuilder\local\helpers\custom_fields; +use core_reportbuilder\local\helpers\format; +use core_reportbuilder\local\report\column; +use core_reportbuilder\local\report\filter; +use html_writer; +use lang_string; +use stdClass; + +defined('MOODLE_INTERNAL') || die(); + +global $CFG; +require_once($CFG->dirroot . '/course/lib.php'); + +/** + * Course entity class implementation + * + * This entity defines all the course columns and filters to be used in any report. + * + * @package core_reportbuilder + * @copyright 2021 Sara Arjona based on Marina Glancy code. + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class course extends base { + + /** + * Database tables that this entity uses and their default aliases. + * + * @return array + */ + protected function get_default_table_aliases(): array { + return [ + 'course' => 'c', + 'context' => 'cctx', + ]; + } + + /** + * The default machine-readable name for this entity that will be used in the internal names of the columns/filters. + * + * @return string + */ + protected function get_default_entity_name(): string { + return 'course'; + } + + /** + * The default title for this entity in the list of columns/filters in the report builder. + * + * @return lang_string + */ + protected function get_default_entity_title(): lang_string { + return new lang_string('entitycourse', 'core_reportbuilder'); + } + + /** + * Get custom fields helper + * + * @return custom_fields + */ + protected function get_custom_fields(): custom_fields { + $customfields = new custom_fields($this->get_table_alias('course') . '.id', $this->get_entity_name(), + 'core_course', 'course'); + $customfields->add_joins($this->get_joins()); + return $customfields; + } + + /** + * Initialise the entity, adding all course and custom course fields + * + * @return base + */ + public function initialise(): base { + $customfields = $this->get_custom_fields(); + + $columns = array_merge($this->get_all_columns(), $customfields->get_columns()); + foreach ($columns as $column) { + $this->add_column($column); + } + + $filters = array_merge($this->get_all_filters(), $customfields->get_filters()); + foreach ($filters as $filter) { + $this->add_filter($filter); + } + + return $this; + } + + /** + * Course fields. + * + * @return array + */ + protected function get_course_fields(): array { + return [ + 'fullname' => new lang_string('fullnamecourse'), + 'shortname' => new lang_string('shortnamecourse'), + 'category' => new lang_string('coursecategory'), + 'idnumber' => new lang_string('idnumbercourse'), + 'summary' => new lang_string('coursesummary'), + 'format' => new lang_string('format'), + 'startdate' => new lang_string('startdate'), + 'enddate' => new lang_string('enddate'), + 'visible' => new lang_string('coursevisibility'), + 'groupmode' => new lang_string('groupmode', 'group'), + 'groupmodeforce' => new lang_string('groupmodeforce', 'group'), + 'lang' => new lang_string('forcelanguage'), + 'calendartype' => new lang_string('forcecalendartype', 'calendar'), + 'theme' => new lang_string('forcetheme'), + 'enablecompletion' => new lang_string('enablecompletion', 'completion'), + 'downloadcontent' => new lang_string('downloadcoursecontent', 'course'), + ]; + } + + /** + * Check if this field is sortable + * + * @param string $fieldname + * @return bool + */ + protected function is_sortable(string $fieldname): bool { + // Some columns can't be sorted, like longtext or images. + $nonsortable = [ + 'summary', + ]; + + return !in_array($fieldname, $nonsortable); + } + + /** + * Return appropriate column type for given user field + * + * @param string $coursefield + * @return int + */ + protected function get_course_field_type(string $coursefield): int { + switch ($coursefield) { + case 'downloadcontent': + case 'enablecompletion': + case 'groupmodeforce': + case 'visible': + $fieldtype = column::TYPE_BOOLEAN; + break; + case 'startdate': + case 'enddate': + $fieldtype = column::TYPE_TIMESTAMP; + break; + case 'summary': + $fieldtype = column::TYPE_LONGTEXT; + break; + case 'category': + case 'groupmode': + $fieldtype = column::TYPE_INTEGER; + break; + case 'calendartype': + case 'idnumber': + case 'format': + case 'fullname': + case 'lang': + case 'shortname': + case 'theme': + default: + $fieldtype = column::TYPE_TEXT; + break; + } + + return $fieldtype; + } + + /** + * Returns list of all available columns. + * + * These are all the columns available to use in any report that uses this entity. + * + * @return column[] + */ + protected function get_all_columns(): array { + $columns = []; + $coursefields = $this->get_course_fields(); + $tablealias = $this->get_table_alias('course'); + $contexttablealias = $this->get_table_alias('context'); + + // Columns course full name with link, course short name with link and course id with link. + $fields = [ + 'coursefullnamewithlink' => 'fullname', + 'courseshortnamewithlink' => 'shortname', + 'courseidnumberewithlink' => 'idnumber', + ]; + foreach ($fields as $key => $field) { + $columns[] = (new column( + $key, + new lang_string($key, 'core_reportbuilder'), + $this->get_entity_name() + )) + ->add_joins($this->get_joins()) + ->set_type(column::TYPE_TEXT) + ->add_fields("{$tablealias}.{$field} as $key, {$tablealias}.id") + ->set_is_sortable(true) + ->add_callback(static function(?string $value, stdClass $row): string { + if ($value === null) { + return ''; + } + + return html_writer::link(course_get_url($row->id), $value); + }); + } + + foreach ($coursefields as $coursefield => $coursefieldlang) { + $column = (new column( + $coursefield, + $coursefieldlang, + $this->get_entity_name() + )) + ->add_joins($this->get_joins()) + ->set_type($this->get_course_field_type($coursefield)) + ->add_field("$tablealias.$coursefield") + ->add_callback([$this, 'format'], $coursefield) + ->set_is_sortable($this->is_sortable($coursefield)); + + // Join on the context table so that we can use it for formatting these columns later. + if ($coursefield === 'summary' || $coursefield === 'shortname' || $coursefield === 'fullname') { + $join = "LEFT JOIN {context} {$contexttablealias} + ON {$contexttablealias}.contextlevel = " . CONTEXT_COURSE . " + AND {$contexttablealias}.instanceid = {$tablealias}.id"; + + $column->add_join($join) + ->add_field("{$tablealias}.id", 'courseid') + ->add_fields(context_helper::get_preload_record_columns_sql($contexttablealias)); + } + + $columns[] = $column; + } + + return $columns; + } + + /** + * Returns list of all available filters + * + * @return array + */ + protected function get_all_filters(): array { + global $DB; + + $filters = []; + $tablealias = $this->get_table_alias('course'); + + $fields = $this->get_course_fields(); + foreach ($fields as $field => $name) { + // Filtering isn't supported for LONGTEXT fields on Oracle. + if ($this->get_course_field_type($field) === column::TYPE_LONGTEXT && + $DB->get_dbfamily() === 'oracle') { + + continue; + } + + $optionscallback = [static::class, 'get_options_for_' . $field]; + if (is_callable($optionscallback)) { + $filterclass = select::class; + } else if ($this->get_course_field_type($field) === column::TYPE_BOOLEAN) { + $filterclass = boolean_select::class; + } else if ($this->get_course_field_type($field) === column::TYPE_TIMESTAMP) { + $filterclass = date::class; + } else { + $filterclass = text::class; + } + + $filter = (new filter( + $filterclass, + $field, + $name, + $this->get_entity_name(), + "{$tablealias}.$field" + )) + ->add_joins($this->get_joins()); + + // Populate filter options by callback, if available. + if (is_callable($optionscallback)) { + $filter->set_options_callback($optionscallback); + } + + $filters[] = $filter; + } + + // We add our own custom course selector filter. + $filters[] = (new filter( + course_selector::class, + 'courseselector', + new lang_string('courses'), + $this->get_entity_name(), + "{$tablealias}.id" + )) + ->add_joins($this->get_joins()); + + return $filters; + } + + /** + * Gets list of options if the filter supports it + * + * @param string $fieldname + * @return null|array + */ + protected function get_options_for(string $fieldname): ?array { + static $cached = []; + if (!array_key_exists($fieldname, $cached)) { + $callable = [static::class, 'get_options_for_' . $fieldname]; + if (is_callable($callable)) { + $cached[$fieldname] = $callable(); + } else { + $cached[$fieldname] = null; + } + } + return $cached[$fieldname]; + } + + /** + * List of options for the field groupmode. + * + * @return array + */ + public static function get_options_for_groupmode(): array { + return [ + NOGROUPS => get_string('groupsnone', 'group'), + SEPARATEGROUPS => get_string('groupsseparate', 'group'), + VISIBLEGROUPS => get_string('groupsvisible', 'group'), + ]; + } + + /** + * List of options for the field category. + * + * @return array + */ + public static function get_options_for_category(): array { + return core_course_category::make_categories_list('moodle/category:viewcourselist'); + } + + /** + * List of options for the field format. + * + * @return array + */ + public static function get_options_for_format(): array { + global $CFG; + require_once($CFG->dirroot.'/course/lib.php'); + + $options = []; + + $courseformats = get_sorted_course_formats(true); + foreach ($courseformats as $courseformat) { + $options[$courseformat] = get_string('pluginname', "format_{$courseformat}"); + } + + return $options; + } + + /** + * List of options for the field theme. + * + * @return array + */ + public static function get_options_for_theme(): array { + $options = []; + + $themeobjects = get_list_of_themes(); + foreach ($themeobjects as $key => $theme) { + if (empty($theme->hidefromselector)) { + $options[$key] = get_string('pluginname', "theme_{$theme->name}"); + } + } + + return $options; + } + + /** + * List of options for the field lang. + * + * @return array + */ + public static function get_options_for_lang(): array { + return get_string_manager()->get_list_of_translations(); + } + + /** + * List of options for the field. + * + * @return array + */ + public static function get_options_for_calendartype(): array { + return \core_calendar\type_factory::get_list_of_calendar_types(); + } + + /** + * Formats the course field for display. + * + * @param mixed $value Current field value. + * @param stdClass $row Complete row. + * @param string $fieldname Name of the field to format. + * @return string + */ + public function format($value, stdClass $row, string $fieldname): string { + if ($this->get_course_field_type($fieldname) === column::TYPE_TIMESTAMP) { + return format::userdate($value, $row); + } + + $options = $this->get_options_for($fieldname); + if ($options !== null && array_key_exists($value, $options)) { + return $options[$value]; + } + + if ($this->get_course_field_type($fieldname) === column::TYPE_BOOLEAN) { + return format::boolean_as_text($value); + } + + if (in_array($fieldname, ['fullname', 'shortname'])) { + if (!$row->courseid) { + return ''; + } + context_helper::preload_from_record($row); + $context = context_course::instance($row->courseid); + return format_string($value, true, ['context' => $context->id, 'escape' => false]); + } + + if (in_array($fieldname, ['summary'])) { + if (!$row->courseid) { + return ''; + } + context_helper::preload_from_record($row); + $context = context_course::instance($row->courseid); + $summary = file_rewrite_pluginfile_urls($row->summary, 'pluginfile.php', $context->id, 'course', 'summary', null); + return format_text($summary); + } + + return s($value); + } +} diff --git a/reportbuilder/classes/local/entities/user.php b/reportbuilder/classes/local/entities/user.php new file mode 100644 index 00000000000..89c5b704864 --- /dev/null +++ b/reportbuilder/classes/local/entities/user.php @@ -0,0 +1,396 @@ +. + +declare(strict_types=1); + +namespace core_reportbuilder\local\entities; + +use context_system; +use html_writer; +use lang_string; +use moodle_url; +use stdClass; +use core_user\fields; +use core_reportbuilder\local\filters\boolean_select; +use core_reportbuilder\local\filters\date; +use core_reportbuilder\local\filters\select; +use core_reportbuilder\local\filters\text; +use core_reportbuilder\local\helpers\user_profile_fields; +use core_reportbuilder\local\helpers\format; +use core_reportbuilder\local\report\column; +use core_reportbuilder\local\report\filter; + +/** + * User entity class implementation. + * + * This entity defines all the user columns and filters to be used in any report. + * + * @package core_reportbuilder + * @copyright 2020 Sara Arjona based on Marina Glancy code. + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class user extends base { + + /** + * Database tables that this entity uses and their default aliases + * + * @return array + */ + protected function get_default_table_aliases(): array { + return ['user' => 'u']; + } + + /** + * The default title for this entity + * + * @return lang_string + */ + protected function get_default_entity_title(): lang_string { + return new lang_string('entityuser', 'core_reportbuilder'); + } + + /** + * Initialise the entity, add all user fields and all 'visible' user profile fields + * + * @return base + */ + public function initialise(): base { + $userprofilefields = $this->get_user_profile_fields(); + + $columns = array_merge($this->get_all_columns(), $userprofilefields->get_columns()); + foreach ($columns as $column) { + $this->add_column($column); + } + + $filters = array_merge($this->get_all_filters(), $userprofilefields->get_filters()); + foreach ($filters as $filter) { + $this->add_filter($filter); + } + + return $this; + } + + /** + * Get user profile fields helper instance + * + * @return user_profile_fields + */ + protected function get_user_profile_fields(): user_profile_fields { + $userprofilefields = new user_profile_fields($this->get_table_alias('user') . '.id', $this->get_entity_name()); + $userprofilefields->add_joins($this->get_joins()); + return $userprofilefields; + } + + /** + * Returns list of all available columns + * + * These are all the columns available to use in any report that uses this entity. + * + * @return column[] + */ + protected function get_all_columns(): array { + $usertablealias = $this->get_table_alias('user'); + + $fullnameselect = self::get_name_fields_select($usertablealias); + $userpictureselect = fields::for_userpic()->get_sql($usertablealias, false, '', '', false)->selects; + $viewfullnames = has_capability('moodle/site:viewfullnames', context_system::instance()); + + // Fullname column. + $columns[] = (new column( + 'fullname', + new lang_string('fullname'), + $this->get_entity_name() + )) + ->add_joins($this->get_joins()) + ->add_fields($fullnameselect) + ->set_type(column::TYPE_TEXT) + ->set_is_sortable($this->is_sortable('fullname')) + ->add_callback(static function(?string $value, stdClass $row) use ($viewfullnames): string { + if ($value === null) { + return ''; + } + + return fullname($row, $viewfullnames); + }); + + // Formatted fullname columns (with link, picture or both). + $fullnamefields = [ + 'fullnamewithlink' => new lang_string('userfullnamewithlink', 'core_reportbuilder'), + 'fullnamewithpicture' => new lang_string('userfullnamewithpicture', 'core_reportbuilder'), + 'fullnamewithpicturelink' => new lang_string('userfullnamewithpicturelink', 'core_reportbuilder'), + ]; + foreach ($fullnamefields as $fullnamefield => $fullnamelang) { + $column = (new column( + $fullnamefield, + $fullnamelang, + $this->get_entity_name() + )) + ->add_joins($this->get_joins()) + ->add_fields($fullnameselect) + ->add_field("{$usertablealias}.id") + ->set_type(column::TYPE_TEXT) + ->set_is_sortable($this->is_sortable($fullnamefield)) + ->add_callback(static function(?string $value, stdClass $row) use ($fullnamefield, $viewfullnames): string { + global $OUTPUT; + + if ($value === null) { + return ''; + } + + if ($fullnamefield === 'fullnamewithlink') { + return html_writer::link(new moodle_url('/user/profile.php', ['id' => $row->id]), + fullname($row, $viewfullnames)); + } + if ($fullnamefield === 'fullnamewithpicture') { + return $OUTPUT->user_picture($row, ['link' => false, 'alttext' => false]) . + fullname($row, $viewfullnames); + } + if ($fullnamefield === 'fullnamewithpicturelink') { + return html_writer::link(new moodle_url('/user/profile.php', ['id' => $row->id]), + $OUTPUT->user_picture($row, ['link' => false, 'alttext' => false]) . + fullname($row, $viewfullnames)); + } + + return $value; + }); + + // Picture fields need some more data. + if (strpos($fullnamefield, 'picture') !== false) { + $column->add_fields($userpictureselect); + } + + $columns[] = $column; + } + + // Picture column. + $columns[] = (new column( + 'picture', + new lang_string('userpicture', 'core_reportbuilder'), + $this->get_entity_name() + )) + ->add_joins($this->get_joins()) + ->add_fields($userpictureselect) + ->set_type(column::TYPE_INTEGER) + ->set_is_sortable($this->is_sortable('picture')) + ->add_callback(static function (int $value, stdClass $row): string { + global $OUTPUT; + + return !empty($row->id) ? $OUTPUT->user_picture($row, ['link' => false, 'alttext' => false]) : ''; + }); + + // Add all other user fields. + $userfields = $this->get_user_fields(); + foreach ($userfields as $userfield => $userfieldlang) { + $columntype = $this->get_user_field_type($userfield); + + $column = (new column( + $userfield, + $userfieldlang, + $this->get_entity_name() + )) + ->add_joins($this->get_joins()) + ->add_field("{$usertablealias}.{$userfield}") + ->set_type($columntype) + ->set_is_sortable($this->is_sortable($userfield)) + ->add_callback([$this, 'format'], $userfield); + + // Some columns also have specific format callbacks. + if ($userfield === 'country') { + $column->add_callback(static function(string $country): string { + $countries = get_string_manager()->get_list_of_countries(true); + return $countries[$country] ?? ''; + }); + } + + $columns[] = $column; + } + + return $columns; + } + + /** + * Check if this field is sortable + * + * @param string $fieldname + * @return bool + */ + protected function is_sortable(string $fieldname): bool { + // Some columns can't be sorted, like longtext or images. + $nonsortable = [ + 'picture', + ]; + + return !in_array($fieldname, $nonsortable); + } + + /** + * Formats the user field for display. + * + * @param mixed $value Current field value. + * @param stdClass $row Complete row. + * @param string $fieldname Name of the field to format. + * @return string + */ + public function format($value, stdClass $row, string $fieldname): string { + if ($this->get_user_field_type($fieldname) === column::TYPE_BOOLEAN) { + return format::boolean_as_text($value); + } + + if ($this->get_user_field_type($fieldname) === column::TYPE_TIMESTAMP) { + return format::userdate($value, $row); + } + + return s($value); + } + + /** + * Returns a SQL statement to select all user fields necessary for fullname() function + * + * @param string $usertablealias + * @return string + */ + public static function get_name_fields_select(string $usertablealias = 'u'): string { + $userfields = array_map(static function(string $userfield) use ($usertablealias): string { + if (!empty($usertablealias)) { + $userfield = "{$usertablealias}.{$userfield}"; + } + + return $userfield; + }, fields::get_name_fields(true)); + + return implode(', ', $userfields); + } + + /** + * User fields + * + * @return lang_string[] + */ + protected function get_user_fields(): array { + return [ + 'firstname' => new lang_string('firstname'), + 'lastname' => new lang_string('lastname'), + 'email' => new lang_string('email'), + 'city' => new lang_string('city'), + 'country' => new lang_string('country'), + 'firstnamephonetic' => new lang_string('firstnamephonetic'), + 'lastnamephonetic' => new lang_string('lastnamephonetic'), + 'middlename' => new lang_string('middlename'), + 'alternatename' => new lang_string('alternatename'), + 'idnumber' => new lang_string('idnumber'), + 'institution' => new lang_string('institution'), + 'department' => new lang_string('department'), + 'phone1' => new lang_string('phone1'), + 'phone2' => new lang_string('phone2'), + 'address' => new lang_string('address'), + 'lastaccess' => new lang_string('lastaccess'), + 'suspended' => new lang_string('suspended'), + 'confirmed' => new lang_string('confirmed', 'admin'), + 'username' => new lang_string('username'), + 'moodlenetprofile' => new lang_string('moodlenetprofile', 'user'), + ]; + } + + /** + * Return appropriate column type for given user field + * + * @param string $userfield + * @return int + */ + protected function get_user_field_type(string $userfield): int { + switch ($userfield) { + case 'confirmed': + case 'suspended': + $fieldtype = column::TYPE_BOOLEAN; + break; + case 'lastaccess': + $fieldtype = column::TYPE_TIMESTAMP; + break; + default: + $fieldtype = column::TYPE_TEXT; + break; + } + + return $fieldtype; + } + + /** + * Return list of all available filters + * + * @return filter[] + */ + protected function get_all_filters(): array { + global $DB; + + $filters = []; + $tablealias = $this->get_table_alias('user'); + + // Fullname filter. + $canviewfullnames = has_capability('moodle/site:viewfullnames', context_system::instance()); + [$fullnamesql, $fullnameparams] = fields::get_sql_fullname($tablealias, $canviewfullnames); + $filters[] = (new filter( + text::class, + 'fullname', + new lang_string('fullname'), + $this->get_entity_name(), + $fullnamesql, + $fullnameparams + )) + ->add_joins($this->get_joins()); + + // User fields filters. + $fields = $this->get_user_fields(); + foreach ($fields as $field => $name) { + $optionscallback = [static::class, 'get_options_for_' . $field]; + if (is_callable($optionscallback)) { + $classname = select::class; + } else if ($this->get_user_field_type($field) === column::TYPE_BOOLEAN) { + $classname = boolean_select::class; + } else if ($this->get_user_field_type($field) === column::TYPE_TIMESTAMP) { + $classname = date::class; + } else { + $classname = text::class; + } + + $filter = (new filter( + $classname, + $field, + $name, + $this->get_entity_name(), + $tablealias . '.' . $field + )) + ->add_joins($this->get_joins()); + + // Populate filter options by callback, if available. + if (is_callable($optionscallback)) { + $filter->set_options_callback($optionscallback); + } + + $filters[] = $filter; + } + + return $filters; + } + + /** + * List of options for the field country. + * + * @return string[] + */ + public static function get_options_for_country(): array { + return array_map('shorten_text', get_string_manager()->get_list_of_countries()); + } +} diff --git a/reportbuilder/classes/local/filters/base.php b/reportbuilder/classes/local/filters/base.php new file mode 100644 index 00000000000..2f279504ce5 --- /dev/null +++ b/reportbuilder/classes/local/filters/base.php @@ -0,0 +1,88 @@ +. + +declare(strict_types=1); + +namespace core_reportbuilder\local\filters; + +use MoodleQuickForm; +use core_reportbuilder\local\report\filter; + +/** + * Base class for all report filters + * + * Filters provide a form for collecting user input, and then return appropriate SQL fragments based on these values + * + * @package core_reportbuilder + * @copyright 2021 Paul Holden + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +abstract class base { + + /** @var filter $filter */ + protected $filter; + + /** @var string $name */ + protected $name; + + /** + * Do not allow the constructor to be called directly or overridden + * + * @param filter $filter + */ + private function __construct(filter $filter) { + $this->filter = $filter; + $this->name = $filter->get_unique_identifier(); + } + + /** + * Creates an instance of a filter type, based on supplied report filter instance + * + * The report filter instance is used by reports/entities to define what should be filtered against, e.g. a SQL fragment + * + * @param filter $filter The report filter instance + * @return static + */ + final public static function create(filter $filter): self { + $filterclass = $filter->get_filter_class(); + + return new $filterclass($filter); + } + + /** + * Returns the filter header + * + * @return string + */ + final public function get_header(): string { + return $this->filter->get_header(); + } + + /** + * Adds filter-specific form elements + * + * @param MoodleQuickForm $mform + */ + abstract public function setup_form(MoodleQuickForm $mform): void; + + /** + * Returns the filter clauses to be used with SQL where + * + * @param array $values + * @return array [$sql, [...$params]] + */ + abstract public function get_sql_filter(array $values): array; +} diff --git a/reportbuilder/classes/local/filters/boolean_select.php b/reportbuilder/classes/local/filters/boolean_select.php new file mode 100644 index 00000000000..92ae16b8daf --- /dev/null +++ b/reportbuilder/classes/local/filters/boolean_select.php @@ -0,0 +1,104 @@ +. + +declare(strict_types=1); + +namespace core_reportbuilder\local\filters; + +use lang_string; +use MoodleQuickForm; +use core_reportbuilder\local\helpers\database; + +/** + * Boolean report filter + * + * This filter accepts an expression that evaluates to 1 or 0, either a simple field such as "u.suspended", or a more complex + * expression such as "CASE WHEN THEN 1 ELSE 0 END" + * + * @package core_reportbuilder + * @copyright 2021 Paul Holden + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class boolean_select extends base { + + /** @var int Any value */ + public const ANY_VALUE = 0; + + /** @var int Checked */ + public const CHECKED = 1; + + /** @var int Not checked */ + public const NOT_CHECKED = 2; + + /** + * Return an array of operators available for this filter + * + * @return lang_string[] + */ + private function get_operators(): array { + $operators = [ + self::ANY_VALUE => new lang_string('filterisanyvalue', 'core_reportbuilder'), + self::CHECKED => new lang_string('yes'), + self::NOT_CHECKED => new lang_string('no'), + ]; + + return $this->filter->restrict_limited_operators($operators); + } + + /** + * Setup form + * + * @param MoodleQuickForm $mform + */ + public function setup_form(MoodleQuickForm $mform): void { + $operatorlabel = get_string('filterfieldoperator', 'core_reportbuilder', $this->get_header()); + $mform->addElement('select', "{$this->name}_operator", $operatorlabel, $this->get_operators()) + ->setHiddenLabel(true); + + $mform->setType("{$this->name}_operator", PARAM_INT); + $mform->setDefault("{$this->name}_operator", self::ANY_VALUE); + } + + /** + * Return filter SQL + * + * @param array $values + * @return array + */ + public function get_sql_filter(array $values): array { + $fieldsql = $this->filter->get_field_sql(); + $params = $this->filter->get_field_params(); + + $paramname = database::generate_param_name(); + + $operator = $values["{$this->name}_operator"] ?? self::ANY_VALUE; + switch ($operator) { + case self::CHECKED: + $fieldsql .= " = :{$paramname}"; + $params[$paramname] = 1; + break; + case self::NOT_CHECKED: + $fieldsql .= " = :{$paramname}"; + $params[$paramname] = 0; + break; + default: + // Invalid or inactive filter. + return ['', []]; + } + + return [$fieldsql, $params]; + } +} diff --git a/reportbuilder/classes/local/filters/course_selector.php b/reportbuilder/classes/local/filters/course_selector.php new file mode 100644 index 00000000000..8031612a92f --- /dev/null +++ b/reportbuilder/classes/local/filters/course_selector.php @@ -0,0 +1,68 @@ +. + +declare(strict_types=1); + +namespace core_reportbuilder\local\filters; + +use MoodleQuickForm; +use core_reportbuilder\local\helpers\database; + +/** + * Course selector filter class implementation + * + * @package core_reportbuilder + * @copyright 2021 David Matamoros . + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class course_selector extends base { + + /** + * Setup form + * + * @param MoodleQuickForm $mform + */ + public function setup_form(MoodleQuickForm $mform): void { + $options = [ + 'multiple' => true, + ]; + $mform->addElement('course', $this->name . '_values', get_string('selectcourses', 'core_reportbuilder'), $options) + ->setHiddenLabel(true); + } + + /** + * Return filter SQL + * + * @param array $values + * @return array + */ + public function get_sql_filter(array $values): array { + global $DB; + + $fieldsql = $this->filter->get_field_sql(); + $params = $this->filter->get_field_params(); + + $courseids = $values["{$this->name}_values"] ?? []; + if (empty($courseids)) { + return ['', []]; + } + + $paramprefix = database::generate_param_name() . '_'; + [$courseselect, $courseparams] = $DB->get_in_or_equal($courseids, SQL_PARAMS_NAMED, $paramprefix); + + return ["{$fieldsql} $courseselect", array_merge($params, $courseparams)]; + } +} diff --git a/reportbuilder/classes/local/filters/date.php b/reportbuilder/classes/local/filters/date.php new file mode 100644 index 00000000000..5d390e56ec8 --- /dev/null +++ b/reportbuilder/classes/local/filters/date.php @@ -0,0 +1,133 @@ +. + +declare(strict_types=1); + +namespace core_reportbuilder\local\filters; + +use lang_string; +use MoodleQuickForm; +use core_reportbuilder\local\helpers\database; + +/** + * Date report filter + * + * This filter accepts a unix timestamp to perform date filtering on + * + * @package core_reportbuilder + * @copyright 2021 Paul Holden + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class date extends base { + + /** @var int Any value */ + public const DATE_ANY = 0; + + /** @var int Non-empty (positive) value */ + public const DATE_NOT_EMPTY = 1; + + /** @var int Empty (zero) value */ + public const DATE_EMPTY = 2; + + /** @var int Date within defined range */ + public const DATE_RANGE = 3; + + /** + * Return an array of operators available for this filter + * + * @return lang_string[] + */ + private function get_operators(): array { + $operators = [ + self::DATE_ANY => new lang_string('filterisanyvalue', 'core_reportbuilder'), + self::DATE_NOT_EMPTY => new lang_string('filterisnotempty', 'core_reportbuilder'), + self::DATE_EMPTY => new lang_string('filterisempty', 'core_reportbuilder'), + self::DATE_RANGE => new lang_string('filterrange', 'core_reportbuilder'), + ]; + + return $this->filter->restrict_limited_operators($operators); + } + + /** + * Setup form + * + * @param MoodleQuickForm $mform + */ + public function setup_form(MoodleQuickForm $mform): void { + $operatorlabel = get_string('filterfieldoperator', 'core_reportbuilder', $this->get_header()); + $mform->addElement('select', "{$this->name}_operator", $operatorlabel, $this->get_operators())->setHiddenLabel(true); + $mform->setType("{$this->name}_operator", PARAM_INT); + $mform->setDefault("{$this->name}_operator", self::DATE_ANY); + + $mform->addElement('date_selector', "{$this->name}_from", get_string('filterdatefrom', 'core_reportbuilder'), + ['optional' => true]); + $mform->setType("{$this->name}_from", PARAM_INT); + $mform->setDefault("{$this->name}_from", 0); + $mform->hideIf("{$this->name}_from", "{$this->name}_operator", 'neq', self::DATE_RANGE); + + $mform->addElement('date_selector', "{$this->name}_to", get_string('filterdateto', 'core_reportbuilder'), + ['optional' => true]); + $mform->setType("{$this->name}_to", PARAM_INT); + $mform->setDefault("{$this->name}_to", 0); + $mform->hideIf("{$this->name}_to", "{$this->name}_operator", 'neq', self::DATE_RANGE); + } + + /** + * Return filter SQL + * + * @param array $values + * @return array + */ + public function get_sql_filter(array $values): array { + $fieldsql = $this->filter->get_field_sql(); + $params = $this->filter->get_field_params(); + + $operator = $values["{$this->name}_operator"] ?? self::DATE_ANY; + switch ($operator) { + case self::DATE_NOT_EMPTY: + $sql = "{$fieldsql} IS NOT NULL AND {$fieldsql} <> 0"; + break; + case self::DATE_EMPTY: + $sql = "{$fieldsql} IS NULL OR {$fieldsql} = 0"; + break; + case self::DATE_RANGE: + $clauses = []; + + $datefrom = (int)($values["{$this->name}_from"] ?? 0); + if ($datefrom > 0) { + $paramdatefrom = database::generate_param_name(); + $clauses[] = "{$fieldsql} >= :{$paramdatefrom}"; + $params[$paramdatefrom] = $datefrom; + } + + $dateto = (int)($values["{$this->name}_to"] ?? 0); + if ($dateto > 0) { + $paramdateto = database::generate_param_name(); + $clauses[] = "{$fieldsql} < :{$paramdateto}"; + $params[$paramdateto] = $dateto; + } + + $sql = implode(' AND ', $clauses); + + break; + default: + // Invalid or inactive filter. + return ['', []]; + } + + return [$sql, $params]; + } +} diff --git a/reportbuilder/classes/local/filters/number.php b/reportbuilder/classes/local/filters/number.php new file mode 100644 index 00000000000..73eb38983bc --- /dev/null +++ b/reportbuilder/classes/local/filters/number.php @@ -0,0 +1,199 @@ +. + +declare(strict_types=1); + +namespace core_reportbuilder\local\filters; + +use core_reportbuilder\local\helpers\database; + +/** + * Number report filter + * + * @package core_reportbuilder + * @copyright 2021 David Matamoros + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class number extends base { + + /** @var int Any value */ + public const ANY_VALUE = 0; + + /** @var int Is not empty */ + public const IS_NOT_EMPTY = 1; + + /** @var int Is empty */ + public const IS_EMPTY = 2; + + /** @var int Less than */ + public const LESS_THAN = 3; + + /** @var int Greater than */ + public const GREATER_THAN = 4; + + /** @var int Equal to */ + public const EQUAL_TO = 5; + + /** @var int Equal or less than */ + public const EQUAL_OR_LESS_THAN = 6; + + /** @var int Equal or greater than */ + public const EQUAL_OR_GREATER_THAN = 7; + + /** @var int Range */ + public const RANGE = 8; + + /** + * Returns an array of comparison operators + * + * @return array of comparison operators + */ + private function get_operators(): array { + $operators = [ + self::ANY_VALUE => get_string('filterisanyvalue', 'core_reportbuilder'), + self::IS_NOT_EMPTY => get_string('filterisnotempty', 'core_reportbuilder'), + self::IS_EMPTY => get_string('filterisempty', 'core_reportbuilder'), + self::LESS_THAN => get_string('filterlessthan', 'core_reportbuilder'), + self::GREATER_THAN => get_string('filtergreaterthan', 'core_reportbuilder'), + self::EQUAL_TO => get_string('filterisequalto', 'core_reportbuilder'), + self::EQUAL_OR_LESS_THAN => get_string('filterequalorlessthan', 'core_reportbuilder'), + self::EQUAL_OR_GREATER_THAN => get_string('filterequalorgreaterthan', 'core_reportbuilder'), + self::RANGE => get_string('filterrange', 'core_reportbuilder'), + ]; + + return $this->filter->restrict_limited_operators($operators); + } + + /** + * Adds controls specific to this filter in the form. + * + * @param \MoodleQuickForm $mform + */ + public function setup_form(\MoodleQuickForm $mform): void { + $objs = []; + + $objs['select'] = $mform->createElement('select', $this->name . '_operator', null, $this->get_operators()); + $mform->setType($this->name . '_operator', PARAM_INT); + + $objs['text'] = $mform->createElement('text', $this->name . '_value1', null, ['size' => 3]); + $mform->setType($this->name . '_value1', PARAM_INT); + $mform->setDefault($this->name . '_value1', 0); + + $objs['text2'] = $mform->createElement('text', $this->name . '_value2', null, ['size' => 3]); + $mform->setType($this->name . '_value2', PARAM_INT); + $mform->setDefault($this->name . '_value2', 0); + + $mform->addElement('group', $this->name . '_grp', '', $objs, '', false); + + $mform->hideIf($this->name . '_value1', $this->name . '_operator', 'eq', self::ANY_VALUE); + $mform->hideIf($this->name . '_value1', $this->name . '_operator', 'eq', self::IS_NOT_EMPTY); + $mform->hideIf($this->name . '_value1', $this->name . '_operator', 'eq', self::IS_EMPTY); + + $mform->hideIf($this->name . '_value2', $this->name . '_operator', 'noteq', self::RANGE); + } + + /** + * Return filter SQL + * + * @param array $values + * @return array array of two elements - SQL query and named parameters + */ + public function get_sql_filter(array $values) : array { + $operator = $values["{$this->name}_operator"] ?? self::ANY_VALUE; + $value1 = $values["{$this->name}_value1"] ?? null; + $value2 = $values["{$this->name}_value2"] ?? null; + + // Validate filter form values. + if (!$this->validate_filter_values($operator, $value1, $value2)) { + // Filter configuration is invalid. Ignore the filter. + return ['', []]; + } + + $param = database::generate_param_name(); + $param2 = database::generate_param_name(); + $fieldsql = $this->filter->get_field_sql(); + $params = $this->filter->get_field_params(); + + switch ($operator) { + case self::ANY_VALUE: + return ['', []]; + case self::IS_NOT_EMPTY: + $res = "{$fieldsql} IS NOT NULL AND {$fieldsql} <> 0"; + break; + case self::IS_EMPTY: + $res = "{$fieldsql} IS NULL OR {$fieldsql} = 0"; + break; + case self::LESS_THAN: + $res = "{$fieldsql} < :{$param}"; + $params[$param] = $value1; + break; + case self::GREATER_THAN: + $res = "{$fieldsql} > :{$param}"; + $params[$param] = $value1; + break; + case self::EQUAL_TO: + $res = "{$fieldsql} = :{$param}"; + $params[$param] = $value1; + break; + case self::EQUAL_OR_LESS_THAN: + $res = "{$fieldsql} <= :{$param}"; + $params[$param] = $value1; + break; + case self::EQUAL_OR_GREATER_THAN: + $res = "{$fieldsql} >= :{$param}"; + $params[$param] = $value1; + break; + case self::RANGE: + $res = "({$fieldsql} >= :{$param} AND {$fieldsql} <= :{$param2})"; + $params[$param] = $value1; + $params[$param2] = $value2; + break; + default: + // Filter configuration is invalid. Ignore the filter. + return ['', []]; + } + return [$res, $params]; + } + + /** + * Validate filter form values + * + * @param int $operator + * @param int|null $value1 + * @param int|null $value2 + * @return bool + */ + private function validate_filter_values(int $operator, ?int $value1, ?int $value2): bool { + // Check that for any of these operators value1 can not be null. + $requirescomparisonvalue = [ + self::LESS_THAN, + self::GREATER_THAN, + self::EQUAL_TO, + self::EQUAL_OR_LESS_THAN, + self::EQUAL_OR_GREATER_THAN + ]; + if (in_array($operator, $requirescomparisonvalue) && $value1 === null) { + return false; + } + + // When operator is between $value1 and $value2, can not be null. + if (($operator === self::RANGE) && ($value1 === null || $value2 === null)) { + return false; + } + + return true; + } +} diff --git a/reportbuilder/classes/local/filters/select.php b/reportbuilder/classes/local/filters/select.php new file mode 100644 index 00000000000..1ff6cab0cfe --- /dev/null +++ b/reportbuilder/classes/local/filters/select.php @@ -0,0 +1,134 @@ +. + +declare(strict_types=1); + +namespace core_reportbuilder\local\filters; + +use MoodleQuickForm; +use core_reportbuilder\local\helpers\database; + +/** + * Select report filter + * + * @package core_reportbuilder + * @copyright 2021 David Matamoros + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class select extends base { + + /** @var int Any value */ + public const ANY_VALUE = 0; + + /** @var int Equal to */ + public const EQUAL_TO = 1; + + /** @var int Not equal to */ + public const NOT_EQUAL_TO = 2; + + /** + * Returns an array of comparison operators + * + * @return array + */ + private function get_operators(): array { + $operators = [ + self::ANY_VALUE => get_string('filterisanyvalue', 'core_reportbuilder'), + self::EQUAL_TO => get_string('filterisequalto', 'core_reportbuilder'), + self::NOT_EQUAL_TO => get_string('filterisnotequalto', 'core_reportbuilder') + ]; + + return $this->filter->restrict_limited_operators($operators); + } + + /** + * Return the options for the filter as an array, to be used to populate the select input field. These options should be + * specified when creating the filter via the {@see set_options} or {@see set_options_callback} method + * + * @return array + */ + private function get_select_options(): array { + return (array) $this->filter->get_options(); + } + + /** + * Adds controls specific to this filter in the form. + * + * @param MoodleQuickForm $mform + */ + public function setup_form(MoodleQuickForm $mform): void { + $elements = []; + $elements['operator'] = $mform->createElement('select', $this->name . '_operator', null, $this->get_operators()); + + // If a multi-dimensional array is passed, we need to use a different element type. + $options = $this->get_select_options(); + $element = (count($options) == count($options, COUNT_RECURSIVE) ? 'select' : 'selectgroups'); + $elements['value'] = $mform->createElement($element, $this->name . '_value', null, $options); + + $mform->addElement('group', $this->name . '_group', '', $elements, '', false); + + $mform->hideIf($this->name . '_value', $this->name . '_operator', 'eq', self::ANY_VALUE); + } + + /** + * Return filter SQL + * + * Note that operators must be of type integer, while values can be integer or string. + * + * @param array $values + * @return array array of two elements - SQL query and named parameters + */ + public function get_sql_filter(array $values): array { + $name = database::generate_param_name(); + + $operator = $values["{$this->name}_operator"] ?? self::ANY_VALUE; + $value = $values["{$this->name}_value"] ?? 0; + + $fieldsql = $this->filter->get_field_sql(); + $params = $this->filter->get_field_params(); + + // Validate filter form values. + if (!$this->validate_filter_values((int) $operator, $value)) { + // Filter configuration is invalid. Ignore the filter. + return ['', []]; + } + + switch ($operator) { + case self::EQUAL_TO: + $fieldsql .= "=:$name"; + $params[$name] = $value; + break; + case self::NOT_EQUAL_TO: + $fieldsql .= "<>:$name"; + $params[$name] = $value; + break; + default: + return ['', []]; + } + return [$fieldsql, $params]; + } + + /** + * Validate filter form values + * + * @param int|null $operator + * @param mixed|null $value + * @return bool + */ + private function validate_filter_values(?int $operator, $value): bool { + return !($operator === null || $value === ''); + } +} diff --git a/reportbuilder/classes/local/filters/text.php b/reportbuilder/classes/local/filters/text.php new file mode 100644 index 00000000000..b00db726625 --- /dev/null +++ b/reportbuilder/classes/local/filters/text.php @@ -0,0 +1,193 @@ +. + +declare(strict_types=1); + +namespace core_reportbuilder\local\filters; + +use core_reportbuilder\local\helpers\database; + +/** + * Text report filter + * + * @package core_reportbuilder + * @copyright 2021 David Matamoros + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class text extends base { + + /** @var int */ + public const ANY_VALUE = 0; + + /** @var int */ + public const CONTAINS = 1; + + /** @var int */ + public const DOES_NOT_CONTAIN = 2; + + /** @var int */ + public const IS_EQUAL_TO = 3; + + /** @var int */ + public const IS_NOT_EQUAL_TO = 4; + + /** @var int */ + public const STARTS_WITH = 5; + + /** @var int */ + public const ENDS_WITH = 6; + + /** @var int */ + public const IS_EMPTY = 7; + + /** @var int */ + public const IS_NOT_EMPTY = 8; + + /** + * Return an array of operators available for this filter + * + * @return array of comparison operators + */ + private function get_operators() : array { + $operators = [ + self::ANY_VALUE => get_string('filterisanyvalue', 'core_reportbuilder'), + self::CONTAINS => get_string('filtercontains', 'core_reportbuilder'), + self::DOES_NOT_CONTAIN => get_string('filterdoesnotcontain', 'core_reportbuilder'), + self::IS_EQUAL_TO => get_string('filterisequalto', 'core_reportbuilder'), + self::IS_NOT_EQUAL_TO => get_string('filterisnotequalto', 'core_reportbuilder'), + self::STARTS_WITH => get_string('filterstartswith', 'core_reportbuilder'), + self::ENDS_WITH => get_string('filterendswith', 'core_reportbuilder'), + self::IS_EMPTY => get_string('filterisempty', 'core_reportbuilder'), + self::IS_NOT_EMPTY => get_string('filterisnotempty', 'core_reportbuilder') + ]; + + return $this->filter->restrict_limited_operators($operators); + } + + /** + * Adds controls specific to this filter in the form. + * + * Operator selector use the "$this->name . '_operator'" naming convention and the fields to enter custom values should + * use "$this->name . '_value'" or _value1/_value2/... in case there is more than one field for their naming. + * + * @param \MoodleQuickForm $mform + */ + public function setup_form(\MoodleQuickForm $mform): void { + $elements = []; + $elements['operator'] = $mform->createElement('select', $this->name . '_operator', + get_string('filterfieldoperator', 'core_reportbuilder', $this->get_header()), $this->get_operators()); + $elements['value'] = $mform->createElement('text', $this->name . '_value', + get_string('filterfieldvalue', 'core_reportbuilder', $this->get_header())); + + $mform->addElement('group', $this->name . '_group', '', $elements, '', false); + + $mform->setType($this->name . '_value', PARAM_RAW); + $mform->hideIf($this->name . '_value', $this->name . '_operator', 'eq', self::ANY_VALUE); + $mform->hideIf($this->name . '_value', $this->name . '_operator', 'eq', self::IS_EMPTY); + $mform->hideIf($this->name . '_value', $this->name . '_operator', 'eq', self::IS_NOT_EMPTY); + } + + /** + * Return filter SQL + * + * @param array|null $values + * @return array array of two elements - SQL query and named parameters + */ + public function get_sql_filter(?array $values) : array { + global $DB; + $name = database::generate_param_name(); + + if (!$values) { + return ['', []]; + } + + $operator = (int) ($values["{$this->name}_operator"] ?? self::ANY_VALUE); + $value = $values["{$this->name}_value"] ?? ''; + + $fieldsql = $this->filter->get_field_sql(); + $params = $this->filter->get_field_params(); + + // Validate filter form values. + if (!$this->validate_filter_values($operator, $value)) { + // Filter configuration is invalid. Ignore the filter. + return ['', []]; + } + + switch($operator) { + case self::CONTAINS: + $res = $DB->sql_like($fieldsql, ":$name", false, false); + $value = $DB->sql_like_escape($value); + $params[$name] = "%$value%"; + break; + case self::DOES_NOT_CONTAIN: + $res = $DB->sql_like($fieldsql, ":$name", false, false, true); + $value = $DB->sql_like_escape($value); + $params[$name] = "%$value%"; + break; + case self::IS_EQUAL_TO: + $res = $DB->sql_equal($fieldsql, ":$name", false, false); + $params[$name] = $value; + break; + case self::IS_NOT_EQUAL_TO: + $res = $DB->sql_equal($fieldsql, ":$name", false, false, true); + $params[$name] = $value; + break; + case self::STARTS_WITH: + $res = $DB->sql_like($fieldsql, ":$name", false, false); + $value = $DB->sql_like_escape($value); + $params[$name] = "$value%"; + break; + case self::ENDS_WITH: + $res = $DB->sql_like($fieldsql, ":$name", false, false); + $value = $DB->sql_like_escape($value); + $params[$name] = "%$value"; + break; + case self::IS_EMPTY: // Note we also account for field not existing here. + $res = "COALESCE({$fieldsql}, '') = :{$name}"; + $params[$name] = ''; + break; + case self::IS_NOT_EMPTY: + $res = "COALESCE({$fieldsql}, '') != :{$name}"; + $params[$name] = ''; + break; + default: + // Filter configuration is invalid. Ignore the filter. + return ['', []]; + } + return array($res, $params); + } + + /** + * Validate filter form values + * + * @param int $operator + * @param string|null $value + * @return bool + */ + private function validate_filter_values(int $operator, ?string $value): bool { + $operatorsthatdontrequirevalue = [ + self::ANY_VALUE, + self::IS_EMPTY, + self::IS_NOT_EMPTY, + ]; + + if ($value === '' && !in_array($operator, $operatorsthatdontrequirevalue)) { + return false; + } + + return true; + } +} diff --git a/reportbuilder/classes/local/helpers/custom_fields.php b/reportbuilder/classes/local/helpers/custom_fields.php new file mode 100644 index 00000000000..8f39aa23dd1 --- /dev/null +++ b/reportbuilder/classes/local/helpers/custom_fields.php @@ -0,0 +1,296 @@ +. + +declare(strict_types=1); + +namespace core_reportbuilder\local\helpers; + +use core_reportbuilder\local\filters\boolean_select; +use core_reportbuilder\local\filters\date; +use core_reportbuilder\local\filters\number; +use core_reportbuilder\local\filters\select; +use core_reportbuilder\local\filters\text; +use core_reportbuilder\local\report\column; +use core_reportbuilder\local\report\filter; +use lang_string; +use stdClass; +use core_customfield\data; +use core_customfield\data_controller; +use core_customfield\field_controller; +use core_customfield\handler; + +/** + * Helper class for course custom fields. + * + * @package core_reportbuilder + * @copyright 2021 Sara Arjona based on David Matamoros code. + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class custom_fields { + + /** @var string $entityname Name of the entity */ + private $entityname; + + /** @var handler $handler The handler for the customfields */ + private $handler; + + /** @var int $tablefieldalias The table alias and the field name (table.field) that matches the customfield instanceid. */ + private $tablefieldalias; + + /** @var array additional joins */ + private $joins = []; + + /** + * Class customfields constructor. + * + * @param string $tablefieldalias table alias and the field name (table.field) that matches the customfield instanceid. + * @param string $entityname name of the entity in the report where we add custom fields. + * @param string $component component name of full frankenstyle plugin name. + * @param string $area name of the area (each component/plugin may define handlers for multiple areas). + * @param int $itemid item id if the area uses them (usually not used). + */ + public function __construct(string $tablefieldalias, string $entityname, string $component, string $area, int $itemid = 0) { + $this->tablefieldalias = $tablefieldalias; + $this->entityname = $entityname; + $this->handler = handler::get_handler($component, $area, $itemid); + } + + /** + * Additional join that is needed. + * + * @param string $join + * @return self + */ + public function add_join(string $join): self { + $this->joins[trim($join)] = trim($join); + return $this; + } + + /** + * Additional joins that are needed. + * + * @param array $joins + * @return self + */ + public function add_joins(array $joins): self { + foreach ($joins as $join) { + $this->add_join($join); + } + return $this; + } + + /** + * Return joins + * + * @return string[] + */ + private function get_joins(): array { + return array_values($this->joins); + } + + /** + * Gets the custom fields columns for the report. + * + * Column will be named as 'customfield_' + customfield shortname. + * + * @return column[] + */ + public function get_columns(): array { + $columns = []; + + $categorieswithfields = $this->handler->get_categories_with_fields(); + foreach ($categorieswithfields as $fieldcategory) { + $categoryfields = $fieldcategory->get_fields(); + foreach ($categoryfields as $field) { + $customdatatablealias = database::generate_alias(); + + $datacontroller = data_controller::create(0, null, $field); + $datafield = $datacontroller->datafield(); + + $alias = 'customfield_' . $field->get('shortname'); + $selectfields = "{$customdatatablealias}.{$datafield} as {$alias}, {$customdatatablealias}.id"; + if ($datafield === 'value') { + // We will take the format into account when displaying the individual values. + $selectfields .= ", {$customdatatablealias}.valueformat"; + } + + $columnname = $field->get_formatted_name(); + $columntype = $this->get_column_type($field, $datafield); + + $newcolumn = (new column( + 'customfield_' . $field->get('shortname'), + new lang_string('customfieldcolumn', 'core_reportbuilder', $columnname), + $this->entityname + )) + ->add_joins($this->get_joins()) + ->add_join("LEFT JOIN {customfield_data} {$customdatatablealias} " . + "ON {$customdatatablealias}.fieldid = " . $field->get('id') . " " . + "AND {$customdatatablealias}.instanceid = {$this->tablefieldalias}") + ->add_fields($selectfields) + ->set_type($columntype) + ->set_is_sortable($columntype !== column::TYPE_LONGTEXT) + ->add_callback([$this, 'customfield_value'], $field) + // Important. If the handler implements can_view() function, it will be called with parameter $instanceid=0. + // This means that per-instance access validation will be ignored. + ->set_is_available($this->handler->can_view($field, 0)); + + $columns[] = $newcolumn; + } + } + return $columns; + } + + /** + * Returns the column type + * + * @param field_controller $field + * @param string $datafield + * @return int + */ + private function get_column_type(field_controller $field, string $datafield): int { + if ($field->get('type') === 'checkbox') { + return column::TYPE_BOOLEAN; + } + + if ($field->get('type') === 'date') { + return column::TYPE_TIMESTAMP; + } + + if ($datafield === 'intvalue') { + return column::TYPE_INTEGER; + } + + if ($datafield === 'decvalue') { + return column::TYPE_FLOAT; + } + + if ($datafield === 'value') { + return column::TYPE_LONGTEXT; + } + + return column::TYPE_TEXT; + } + + /** + * Returns all available filters on custom fields. + * + * Filter will be named as 'customfield_' + customfield shortname. + * + * @return filter[] + */ + public function get_filters(): array { + $filters = []; + + $categorieswithfields = $this->handler->get_categories_with_fields(); + foreach ($categorieswithfields as $fieldcategory) { + $categoryfields = $fieldcategory->get_fields(); + foreach ($categoryfields as $field) { + $customdatatablealias = database::generate_alias(); + + $datacontroller = data_controller::create(0, null, $field); + $datafield = $datacontroller->datafield(); + $typeclass = $this->get_filter_class_type($datacontroller); + + $filter = (new filter( + $typeclass, + 'customfield_' . $field->get('shortname'), + new lang_string('customfieldcolumn', 'core_reportbuilder', $field->get('name')), + $this->entityname, + "{$customdatatablealias}.{$datafield}" + )) + ->add_joins($this->get_joins()) + ->add_join("LEFT JOIN {customfield_data} {$customdatatablealias} " . + "ON {$customdatatablealias}.fieldid = " . $field->get('id') . " " . + "AND {$customdatatablealias}.instanceid = {$this->tablefieldalias}"); + + // Options are stored inside configdata json string and we need to convert it to array. + if ($field->get('type') === 'select') { + $filter->set_options_callback(static function() use ($field): array { + $options = explode("\r\n", $field->get_configdata_property('options')); + // Method set_options starts using array at index 1. we shift one position on this array. + // In course settings this menu has an empty option and we need to respect that. + array_unshift($options, " "); + unset($options[0]); + return $options; + }); + } + + $filters[] = $filter; + } + } + return $filters; + } + + /** + * Returns class for the filter element that should be used for the field + * + * In some situation we can assume what kind of data is stored in the customfield plugin and we can + * display appropriate filter form element. For all others assume text filter. + * + * @param data_controller $datacontroller + * @return string + */ + private function get_filter_class_type(data_controller $datacontroller): string { + $type = $datacontroller->get_field()->get('type'); + + switch ($type) { + case 'checkbox': + $classtype = boolean_select::class; + break; + case 'date': + $classtype = date::class; + break; + case 'select': + $classtype = select::class; + break; + default: + // To support third party field type we need to account for stored numbers. + $datafield = $datacontroller->datafield(); + if ($datafield === 'intvalue' || $datafield === 'decvalue') { + $classtype = number::class; + } else { + $classtype = text::class; + } + break; + } + + return $classtype; + } + + /** + * Format for custom fields value. We get the correct custom field value using export_value method. + * + * @param mixed $value Current value. + * @param stdClass $row Full row. + * @param field_controller $field Field controller object. + * @return mixed|null + */ + public function customfield_value($value, stdClass $row, field_controller $field) { + $defaults = [ + 'id' => -1, + 'shortcharvalue' => $value, + 'charvalue' => $value, + 'intvalue' => (int)$value, + 'decvalue' => (float)$value, + 'value' => $value, + 'fieldid' => $field->get('id'), + 'valueformat' => FORMAT_HTML, + ]; + $row = array_intersect_key(array_merge($defaults, (array)$row), data::properties_definition()); + $data = data_controller::create(0, (object)$row, $field); + return $data->export_value(); + } +} diff --git a/reportbuilder/classes/local/helpers/database.php b/reportbuilder/classes/local/helpers/database.php new file mode 100644 index 00000000000..e391504a44f --- /dev/null +++ b/reportbuilder/classes/local/helpers/database.php @@ -0,0 +1,77 @@ +. + +declare(strict_types=1); + +namespace core_reportbuilder\local\helpers; + +use coding_exception; + +/** + * Helper functions for DB manipulations + * + * @package core_reportbuilder + * @copyright 2019 Marina Glancy + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class database { + + /** @var string Prefix for generated aliases */ + private const GENERATE_ALIAS_PREFIX = 'rbalias'; + + /** @var string Prefix for generated param names names */ + private const GENERATE_PARAM_PREFIX = 'rbparam'; + + /** + * Generates unique table/column alias that must be used in generated SQL + * + * @return string + */ + public static function generate_alias(): string { + static $aliascount = 0; + + return static::GENERATE_ALIAS_PREFIX . ($aliascount++); + } + /** + * Generates unique parameter name that must be used in generated SQL + * + * @return string + */ + public static function generate_param_name(): string { + static $paramcount = 0; + + return static::GENERATE_PARAM_PREFIX . ($paramcount++); + } + + /** + * Validate that parameter names were generated using {@see generate_param_name}. + * + * @param array $params + * @return bool + * @throws coding_exception For invalid params. + */ + public static function validate_params(array $params): bool { + $nonmatchingkeys = array_filter($params, static function($key): bool { + return !preg_match('/^' . static::GENERATE_PARAM_PREFIX . '[\d]+/', $key); + }, ARRAY_FILTER_USE_KEY); + + if (!empty($nonmatchingkeys)) { + throw new coding_exception('Invalid parameter names', implode(', ', array_keys($nonmatchingkeys))); + } + + return true; + } +} diff --git a/reportbuilder/classes/local/helpers/format.php b/reportbuilder/classes/local/helpers/format.php new file mode 100644 index 00000000000..660a3f1554c --- /dev/null +++ b/reportbuilder/classes/local/helpers/format.php @@ -0,0 +1,53 @@ +. + +declare(strict_types=1); + +namespace core_reportbuilder\local\helpers; + +use stdClass; + +/** + * Class containing helper methods for format columns data as callbacks. + * + * @package core_reportbuilder + * @copyright 2021 Sara Arjona based on Alberto Lara Hernández code. + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class format { + + /** + * Returns formatted date. + * + * @param int $value Unix timestamp + * @param stdClass $row + * @param string|null $format Format string for strftime + * @return string + */ + public static function userdate(int $value, stdClass $row, ?string $format = null): string { + return $value ? userdate($value, $format) : ''; + } + + /** + * Returns yes/no string depending on the given value + * + * @param bool $value + * @return string + */ + public static function boolean_as_text(bool $value): string { + return $value ? get_string('yes') : get_string('no'); + } +} diff --git a/reportbuilder/classes/local/helpers/user_filter_manager.php b/reportbuilder/classes/local/helpers/user_filter_manager.php new file mode 100644 index 00000000000..0bf19202498 --- /dev/null +++ b/reportbuilder/classes/local/helpers/user_filter_manager.php @@ -0,0 +1,175 @@ +. + +declare(strict_types=1); + +namespace core_reportbuilder\local\helpers; + +use core_text; + +/** + * This class handles the setting and retrieving of a users' filter values for given reports + * + * It is currently using the user preference API as a storage mechanism + * + * @package core_reportbuilder + * @copyright 2021 Paul Holden + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class user_filter_manager { + + /** @var int The size of each chunk, matching the maximum length of a single user preference */ + private const PREFERENCE_CHUNK_SIZE = 1333; + + /** @var string The prefix used to name the stored user preferences */ + private const PREFERENCE_NAME_PREFIX = 'reportbuilder-report-'; + + /** + * Generate user preference name for given report + * + * @param int $reportid + * @param int $index + * @return string + */ + private static function user_preference_name(int $reportid, int $index): string { + return static::PREFERENCE_NAME_PREFIX . "{$reportid}-{$index}"; + } + + /** + * Set user filters for given report + * + * @param int $reportid + * @param array $values + * @param int|null $userid + * @return bool + */ + public static function set(int $reportid, array $values, int $userid = null): bool { + $jsonvalues = json_encode($values); + + $jsonchunks = str_split($jsonvalues, static::PREFERENCE_CHUNK_SIZE); + foreach ($jsonchunks as $index => $jsonchunk) { + $userpreference = static::user_preference_name($reportid, $index); + set_user_preference($userpreference, $jsonchunk, $userid); + } + + // Ensure any subsequent preferences are reset (to account for number of chunks decreasing). + static::reset_all($reportid, $userid, $index + 1); + + return true; + } + + /** + * Get user filters for given report + * + * @param int $reportid + * @param int|null $userid + * @return array + */ + public static function get(int $reportid, int $userid = null): array { + $jsonvalues = ''; + $index = 0; + + // We'll repeatedly append chunks to our JSON string, until we hit one that is below the maximum length. + do { + $userpreference = static::user_preference_name($reportid, $index++); + $jsonchunk = get_user_preferences($userpreference, '', $userid); + $jsonvalues .= $jsonchunk; + } while (core_text::strlen($jsonchunk) === static::PREFERENCE_CHUNK_SIZE); + + return (array) json_decode($jsonvalues); + } + + /** + * Merge individual user filter values for given report + * + * @param int $reportid + * @param array $values + * @param int|null $userid + * @return bool + */ + public static function merge(int $reportid, array $values, int $userid = null): bool { + $existing = static::get($reportid, $userid); + + return static::set($reportid, array_merge($existing, $values), $userid); + } + + /** + * Reset all user filters for given report + * + * @param int $reportid + * @param int|null $userid + * @param int $index If specified, then preferences will be reset starting from this index + * @return bool + */ + public static function reset_all(int $reportid, int $userid = null, int $index = 0): bool { + // We'll repeatedly retrieve and reset preferences, until we hit one that is below the maximum length. + do { + $userpreference = static::user_preference_name($reportid, $index++); + $jsonchunk = get_user_preferences($userpreference, '', $userid); + unset_user_preference($userpreference, $userid); + } while (core_text::strlen($jsonchunk) === static::PREFERENCE_CHUNK_SIZE); + + return true; + } + + /** + * Reset single user filter for given report + * + * @param int $reportid + * @param string $uniqueidentifier + * @param int|null $userid + * @return bool + */ + public static function reset_single(int $reportid, string $uniqueidentifier, int $userid = null): bool { + $originalvalues = static::get($reportid, $userid); + + // Remove any filters whose name is prefixed by given identifier. + $values = array_filter($originalvalues, static function(string $filterkey) use ($uniqueidentifier): bool { + return core_text::strpos($filterkey, $uniqueidentifier) !== 0; + }, ARRAY_FILTER_USE_KEY); + + return static::set($reportid, $values, $userid); + } + + /** + * Get all report filters for given user + * + * This is primarily designed for the privacy provider, and allows us to preserve all the preference logic within this class. + * + * @param int $userid + * @return array + */ + public static function get_all_for_user(int $userid): array { + global $DB; + $prefs = []; + + // We need to locate the first preference chunk of all report filters. + $select = 'userid = :userid AND ' . $DB->sql_like('name', ':namelike'); + $params = [ + 'userid' => $userid, + 'namelike' => $DB->sql_like_escape(static::PREFERENCE_NAME_PREFIX) . '%-0', + ]; + $preferences = $DB->get_fieldset_select('user_preferences', 'name', $select, $params); + + // Retrieve all found filters. + foreach ($preferences as $preference) { + preg_match('/^' . static::PREFERENCE_NAME_PREFIX . '(?\d+)\-/', $preference, $matches); + $prefs[static::PREFERENCE_NAME_PREFIX . $matches['reportid']] = static::get((int) $matches['reportid'], $userid); + } + + return $prefs; + } +} diff --git a/reportbuilder/classes/local/helpers/user_profile_fields.php b/reportbuilder/classes/local/helpers/user_profile_fields.php new file mode 100644 index 00000000000..118354cc6d1 --- /dev/null +++ b/reportbuilder/classes/local/helpers/user_profile_fields.php @@ -0,0 +1,242 @@ +. + +declare(strict_types=1); + +namespace core_reportbuilder\local\helpers; + +use context_system; +use core_reportbuilder\local\filters\boolean_select; +use core_reportbuilder\local\filters\date; +use core_reportbuilder\local\filters\select; +use core_reportbuilder\local\filters\text; +use core_reportbuilder\local\report\column; +use core_reportbuilder\local\report\filter; +use lang_string; +use profile_field_base; +use stdClass; + +defined('MOODLE_INTERNAL') || die(); + +global $CFG; +require_once($CFG->dirroot.'/user/profile/lib.php'); + +/** + * Helper class for user profile fields. + * + * @package core_reportbuilder + * @copyright 2021 David Matamoros + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class user_profile_fields { + + /** @var array user profile fields */ + private $userprofilefields; + + /** @var string $entityname Name of the entity */ + private $entityname; + + /** @var int $usertablefieldalias The user table/field alias */ + private $usertablefieldalias; + + /** @var array additional joins */ + private $joins = []; + + /** + * Class userprofilefields constructor. + * + * @param string $usertablefieldalias The user table/field alias used when adding columns and filters. + * @param string $entityname The entity name used when adding columns and filters. + */ + public function __construct(string $usertablefieldalias, string $entityname) { + $this->usertablefieldalias = $usertablefieldalias; + $this->entityname = $entityname; + $this->userprofilefields = $this->get_user_profile_fields(); + } + + /** + * Retrieves the list of available/visible user profile fields + * + * @return profile_field_base[] + */ + private function get_user_profile_fields(): array { + return array_filter(profile_get_user_fields_with_data(0), static function($profilefield): bool { + return (int)$profilefield->field->visible === (int)PROFILE_VISIBLE_ALL; + }); + } + + /** + * Additional join that is needed. + * + * @param string $join + * @return self + */ + public function add_join(string $join): self { + $this->joins[trim($join)] = trim($join); + return $this; + } + + /** + * Additional joins that are needed. + * + * @param array $joins + * @return self + */ + public function add_joins(array $joins): self { + foreach ($joins as $join) { + $this->add_join($join); + } + return $this; + } + + /** + * Return joins + * + * @return string[] + */ + private function get_joins(): array { + return array_values($this->joins); + } + + /** + * Return the user profile fields visible columns. + * + * @return column[] + */ + public function get_columns(): array { + $columns = []; + + foreach ($this->userprofilefields as $profilefield) { + $userinfotablealias = database::generate_alias(); + + $column = (new column( + 'profilefield_' . $profilefield->field->shortname, + new lang_string('customfieldcolumn', 'core_reportbuilder', + format_string($profilefield->field->name, true, + ['escape' => true, 'context' => context_system::instance()])), + $this->entityname + )) + ->add_joins($this->get_joins()) + ->add_join("LEFT JOIN {user_info_data} {$userinfotablealias} " . + "ON {$userinfotablealias}.userid = {$this->usertablefieldalias} " . + "AND {$userinfotablealias}.fieldid = {$profilefield->fieldid}") + ->add_field("{$userinfotablealias}.data", 'profilefield_' . $profilefield->field->shortname) + ->set_type($this->get_user_field_type($profilefield->field->datatype)) + ->add_callback([$this, 'format_profile_field'], $profilefield); + + $columns[] = $column; + } + + return $columns; + } + + /** + * Get custom user profile fields filters. + * + * @return filter[] + */ + public function get_filters(): array { + global $DB; + + $filters = []; + foreach ($this->userprofilefields as $profilefield) { + $userinfotablealias = database::generate_alias(); + $field = "{$userinfotablealias}.data"; + + switch ($profilefield->field->datatype) { + case 'checkbox': + $classname = boolean_select::class; + $field = $DB->sql_cast_char2int($field); + break; + case 'datetime': + $classname = date::class; + $field = $DB->sql_cast_char2int($field); + break; + case 'menu': + $classname = select::class; + break; + case 'text': + case 'textarea': + default: + $field = $DB->sql_compare_text($field, 255); + $classname = text::class; + break; + } + + $filter = (new filter( + $classname, + 'profilefield_' . $profilefield->field->shortname, + new lang_string('customfieldcolumn', 'core_reportbuilder', + format_string($profilefield->field->name, true, + ['escape' => false, 'context' => context_system::instance()])), + $this->entityname, + $field + )) + ->add_joins($this->get_joins()) + ->add_join("LEFT JOIN {user_info_data} {$userinfotablealias} " . + "ON {$userinfotablealias}.userid = {$this->usertablefieldalias} " . + "AND {$userinfotablealias}.fieldid = {$profilefield->fieldid}"); + + // If menu type then set filter options as appropriate. + if ($profilefield->field->datatype === 'menu') { + $filter->set_options($profilefield->options); + } + + $filters[] = $filter; + } + + return $filters; + } + + /** + * Get user profile field type for report. + * + * @param string $userfield user field. + * @return int the constant equivalent to this custom field type. + */ + protected function get_user_field_type(string $userfield): int { + switch ($userfield) { + case 'checkbox': + $customfieldtype = column::TYPE_BOOLEAN; + break; + case 'datetime': + $customfieldtype = column::TYPE_TIMESTAMP; + break; + case 'textarea': + $customfieldtype = column::TYPE_LONGTEXT; + break; + case 'menu': + case 'text': + default: + $customfieldtype = column::TYPE_TEXT; + break; + } + return $customfieldtype; + } + + /** + * Formatter for a profile field. It formats the field according to its type. + * + * @param mixed $value + * @param stdClass $row + * @param profile_field_base $field + * @return string + */ + public static function format_profile_field($value, stdClass $row, profile_field_base $field): string { + $field->data = $value; + return $field->display_data(); + } +} diff --git a/reportbuilder/classes/local/models/report.php b/reportbuilder/classes/local/models/report.php new file mode 100644 index 00000000000..0c874e3bcfa --- /dev/null +++ b/reportbuilder/classes/local/models/report.php @@ -0,0 +1,101 @@ +. + +declare(strict_types=1); + +namespace core_reportbuilder\local\models; + +use context; +use context_system; +use core\persistent; +use core_reportbuilder\local\report\base; + +/** + * Persistent class to represent a report + * + * @package core_reportbuilder + * @copyright 2018 Alberto Lara Hernández + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class report extends persistent { + + /** @var string The table name. */ + public const TABLE = 'reportbuilder_report'; + + /** + * Return the definition of the properties of this model + * + * @return array + */ + protected static function define_properties(): array { + return [ + 'source' => [ + 'type' => PARAM_RAW, + ], + 'type' => [ + 'type' => PARAM_INT, + 'choices' => [ + base::TYPE_CUSTOM_REPORT, + base::TYPE_SYSTEM_REPORT, + ], + ], + 'contextid' => [ + 'type' => PARAM_INT, + 'default' => static function(): int { + return context_system::instance()->id; + } + ], + 'component' => [ + 'type' => PARAM_COMPONENT, + 'default' => '', + ], + 'area' => [ + 'type' => PARAM_AREA, + 'default' => '', + ], + 'itemid' => [ + 'type' => PARAM_INT, + 'default' => 0, + ], + 'usercreated' => [ + 'type' => PARAM_INT, + 'default' => static function(): int { + global $USER; + + return (int) $USER->id; + }, + ], + ]; + } + + /** + * Return report ID + * + * @return int + */ + protected function get_id(): int { + return (int) $this->raw_get('id'); + } + + /** + * Return report context, used by exporters + * + * @return context + */ + public function get_context(): context { + return context::instance_by_id($this->raw_get('contextid')); + } +} diff --git a/reportbuilder/classes/local/report/action.php b/reportbuilder/classes/local/report/action.php new file mode 100644 index 00000000000..0add515e354 --- /dev/null +++ b/reportbuilder/classes/local/report/action.php @@ -0,0 +1,131 @@ +. + +declare(strict_types=1); + +namespace core_reportbuilder\local\report; + +use moodle_url; +use pix_icon; +use popup_action; +use stdClass; + +/** + * Class to represent a report action + * + * @package core_reportbuilder + * @copyright 2021 Paul Holden + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +final class action { + + /** @var moodle_url $url */ + protected $url; + + /** @var pix_icon $icon */ + protected $icon; + + /** @var array $attributes */ + protected $attributes; + + /** @var bool $popup */ + protected $popup; + + /** @var callable[] $callbacks */ + protected $callbacks = []; + + /** + * Create an instance of an action to be added to a report. Both the parameters of the URL, and the attributes parameter + * support placeholders which will be replaced with appropriate row values, e.g.: + * + * new action(new moodle_url('/', ['id' => ':id']), new pix_icon(...), ['data-id' => ':id']) + * + * Note that all expected placeholders should be added as base fields to the report + * + * @param moodle_url $url + * @param pix_icon $icon + * @param array $attributes + * @param bool $popup + */ + public function __construct( + moodle_url $url, + pix_icon $icon, + array $attributes = [], + bool $popup = false + ) { + $this->url = $url; + $this->icon = $icon; + $this->attributes = $attributes; + $this->popup = $popup; + } + + /** + * Adds callback to the action. Used to verify action is available to current user, or preprocess values used in placeholders + * + * Multiple callbacks can be added. If at least one returns false then the action will not be displayed + * + * @param callable $callback + * @return self + */ + public function add_callback(callable $callback): self { + $this->callbacks[] = $callback; + return $this; + } + + /** + * Return renderer action icon suitable for output + * + * @uses core_renderer::action_icon() + * + * @param stdClass $row + * @return string|null + */ + public function get_action_link(stdClass $row): ?string { + global $OUTPUT; + + foreach ($this->callbacks as $callback) { + $row = clone $row; // Clone so we don't modify the shared row inside a callback. + if (!$callback($row)) { + return null; + } + } + + // Create a new moodle_url instance with our filled in placeholders for this row. + $url = new moodle_url( + $this->url->out_omit_querystring(true), + self::replace_placeholders($this->url->params(), $row) + ); + + $popupaction = $this->popup ? new popup_action('click', $url) : null; + + return $OUTPUT->action_icon($url, $this->icon, $popupaction, self::replace_placeholders($this->attributes, $row)); + } + + /** + * Given an array of values, replace all placeholders with corresponding property of the given row + * + * @param array $values + * @param stdClass $row + * @return array + */ + private static function replace_placeholders(array $values, stdClass $row): array { + return array_map(static function($value) use ($row) { + return preg_replace_callback('/^:(?.*)$/', static function(array $matches) use ($row): string { + return (string) ($row->{$matches['property']} ?? ''); + }, $value); + }, $values); + } +} diff --git a/reportbuilder/classes/local/report/base.php b/reportbuilder/classes/local/report/base.php new file mode 100644 index 00000000000..cbdac87a6a0 --- /dev/null +++ b/reportbuilder/classes/local/report/base.php @@ -0,0 +1,482 @@ +. + +declare(strict_types=1); + +namespace core_reportbuilder\local\report; + +use coding_exception; +use context; +use lang_string; +use core_reportbuilder\local\entities\base as entity_base; +use core_reportbuilder\local\filters\base as filter_base; +use core_reportbuilder\local\helpers\database; +use core_reportbuilder\local\helpers\user_filter_manager; +use core_reportbuilder\local\models\report; + +/** + * Base class for all reports + * + * @package core_reportbuilder + * @copyright 2020 Paul Holden + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +abstract class base { + + /** @var int Custom report type value */ + public const TYPE_CUSTOM_REPORT = 0; + + /** @var int System report type value */ + public const TYPE_SYSTEM_REPORT = 1; + + /** @var int Default paging limit */ + public const DEFAULT_PAGESIZE = 30; + + /** @var report $report Report persistent */ + private $report; + + /** @var string $maintable */ + private $maintable = ''; + + /** @var string $maintablealias */ + private $maintablealias = ''; + + /** @var array $sqljoins */ + private $sqljoins = []; + + /** @var array $sqlwheres */ + private $sqlwheres = []; + + /** @var array $sqlparams */ + private $sqlparams = []; + + /** @var entity_base[] $entities */ + private $entities = []; + + /** @var lang_string[] */ + private $entitytitles = []; + + /** @var column[] $columns */ + private $columns = []; + + /** @var filter[] $filters */ + private $filters = []; + + /** @var bool $downloadable Set if the report can be downloaded */ + private $downloadable = false; + + /** @var string $downloadfilename Name of the downloaded file */ + private $downloadfilename = ''; + + /** + * Base report constructor + * + * @param report $report + */ + public function __construct(report $report) { + $this->report = $report; + + // Initialise and validate the report. + $this->initialise(); + $this->validate(); + } + + /** + * Returns persistent class used when initialising this report + * + * @return report + */ + final public function get_report_persistent(): report { + return $this->report; + } + + /** + * Initialise report. Specify which columns, filters, etc should be present + * + * To set the base query use: + * - {@see set_main_table} + * - {@see add_base_condition_simple} or {@see add_base_condition_sql} + * - {@see add_join} + * + * To add content to the report use: + * - {@see add_entity} + * - {@see add_column} + * - {@see add_filter} + * - etc + */ + abstract protected function initialise(): void; + + /** + * Output the report + * + * @return string + */ + abstract public function output(): string; + + /** + * Get the report availability. Sub-classes should override this method to declare themselves unavailable, for example if + * they require classes that aren't present due to missing plugin + * + * @return bool + */ + public static function is_available(): bool { + return true; + } + + /** + * Perform some basic validation about expected class properties + * + * @throws coding_exception + */ + protected function validate(): void { + if (empty($this->maintable)) { + throw new coding_exception('Report must define main table by calling $this->set_main_table()'); + } + + if (empty($this->columns)) { + throw new coding_exception('Report must define at least one column by calling $this->add_column()'); + } + } + + /** + * Set the main table and alias for the SQL query + * + * @param string $tablename + * @param string $tablealias + */ + final public function set_main_table(string $tablename, string $tablealias = ''): void { + $this->maintable = $tablename; + $this->maintablealias = $tablealias; + } + + /** + * Get the main table name + * + * @return string + */ + final public function get_main_table(): string { + return $this->maintable; + } + + /** + * Get the alias for the main table + * + * @return string + */ + final public function get_main_table_alias(): string { + return $this->maintablealias; + } + + /** + * Adds report JOIN clause that is always added + * + * @param string $join + * @param array $params + * @param bool $validateparams Some queries might add non-standard params and validation could fail + */ + protected function add_join(string $join, array $params = [], bool $validateparams = true): void { + if ($validateparams) { + database::validate_params($params); + } + + $this->sqljoins[trim($join)] = trim($join); + $this->sqlparams += $params; + } + + /** + * Return report JOIN clauses + * + * @return array + */ + public function get_joins(): array { + return array_values($this->sqljoins); + } + + /** + * Define simple "field = value" clause to apply to the report query + * + * @param string $fieldname + * @param mixed $fieldvalue + */ + final public function add_base_condition_simple(string $fieldname, $fieldvalue): void { + if ($fieldvalue === null) { + $this->add_base_condition_sql("{$fieldname} IS NULL"); + } else { + $fieldvalueparam = database::generate_param_name(); + $this->add_base_condition_sql("{$fieldname} = :{$fieldvalueparam}", [ + $fieldvalueparam => $fieldvalue, + ]); + } + } + + /** + * Define more complex clause that will always be applied to the report query + * + * @param string $where + * @param array $params Note that the param names should be generated by {@see database::generate_param_name} + */ + final public function add_base_condition_sql(string $where, array $params = []): void { + database::validate_params($params); + + $this->sqlwheres[] = trim($where); + $this->sqlparams = $params + $this->sqlparams; + } + + /** + * Return base select/params for the report query + * + * @return array [string $select, array $params] + */ + final public function get_base_condition(): array { + return [ + implode(' AND ', $this->sqlwheres), + $this->sqlparams, + ]; + } + + /** + * Adds given entity, along with it's columns and filters, to the report + * + * @param entity_base $entity + */ + final protected function add_entity(entity_base $entity): void { + $entityname = $entity->get_entity_name(); + $this->annotate_entity($entityname, $entity->get_entity_title()); + $this->entities[$entityname] = $entity->initialise(); + } + + /** + * Define a new entity for the report + * + * @param string $name + * @param lang_string $title + * @throws coding_exception + */ + final protected function annotate_entity(string $name, lang_string $title): void { + if (empty($name) || $name !== clean_param($name, PARAM_ALPHANUMEXT)) { + throw new coding_exception('Entity name must be comprised of alphanumeric character, underscore or dash'); + } + + $this->entitytitles[$name] = $title; + } + + /** + * Adds a column to the report + * + * @param column $column + * @return column + * @throws coding_exception + */ + final protected function add_column(column $column): column { + if (!array_key_exists($column->get_entity_name(), $this->entitytitles)) { + throw new coding_exception('Invalid entity name', $column->get_entity_name()); + } + + $name = $column->get_name(); + if (empty($name) || $name !== clean_param($name, PARAM_ALPHANUMEXT)) { + throw new coding_exception('Column name must be comprised of alphanumeric character, underscore or dash'); + } + + $uniqueidentifier = $column->get_unique_identifier(); + if (array_key_exists($uniqueidentifier, $this->columns)) { + throw new coding_exception('Duplicate column identifier', $uniqueidentifier); + } + + $this->columns[$uniqueidentifier] = $column; + + return $column; + } + + /** + * Add given columns to the report from one or more entities + * + * Each entity must have already been added to the report before calling this method + * + * @param string[] $columns Unique identifier of each entity column + * @throws coding_exception For unknown entities + */ + final protected function add_columns_from_entities(array $columns): void { + foreach ($columns as $column) { + [$entityname, $columnname] = explode(':', $column, 2); + + if (!array_key_exists($entityname, $this->entities)) { + throw new coding_exception('Invalid entity name', $entityname); + } + + $this->add_column($this->entities[$entityname]->get_column($columnname)); + } + } + + /** + * Return report column by unique identifier + * + * @param string $uniqueidentifier + * @return column|null + */ + final public function get_column(string $uniqueidentifier): ?column { + return $this->columns[$uniqueidentifier] ?? null; + } + + /** + * Return all available report columns + * + * @return column[] + */ + final public function get_columns(): array { + return array_filter($this->columns, static function(column $column): bool { + return $column->get_is_available(); + }); + } + + /** + * Adds a filter to the report + * + * @param filter $filter + * @return filter + * @throws coding_exception + */ + final protected function add_filter(filter $filter): filter { + if (!array_key_exists($filter->get_entity_name(), $this->entitytitles)) { + throw new coding_exception('Invalid entity name', $filter->get_entity_name()); + } + + $name = $filter->get_name(); + if (empty($name) || $name !== clean_param($name, PARAM_ALPHANUMEXT)) { + throw new coding_exception('Filter name must be comprised of alphanumeric character, underscore or dash'); + } + + $uniqueidentifier = $filter->get_unique_identifier(); + if (array_key_exists($uniqueidentifier, $this->filters)) { + throw new coding_exception('Duplicate filter identifier', $uniqueidentifier); + } + + $this->filters[$uniqueidentifier] = $filter; + + return $filter; + } + + /** + * Add given filters to the report from one or more entities + * + * Each entity must have already been added to the report before calling this method + * + * @param string[] $filters Unique identifier of each entity filter + * @throws coding_exception For unknown entities + */ + final protected function add_filters_from_entities(array $filters): void { + foreach ($filters as $filter) { + [$entityname, $filtername] = explode(':', $filter, 2); + + if (!array_key_exists($entityname, $this->entities)) { + throw new coding_exception('Invalid entity name', $entityname); + } + + $this->add_filter($this->entities[$entityname]->get_filter($filtername)); + } + } + + /** + * Return report filter by unique identifier + * + * @param string $uniqueidentifier + * @return filter|null + */ + final public function get_filter(string $uniqueidentifier): ?filter { + return $this->filters[$uniqueidentifier] ?? null; + } + + /** + * Return all available report filters + * + * @return filter[] + */ + final public function get_filters(): array { + return array_filter($this->filters, static function(filter $filter): bool { + return $filter->get_is_available(); + }); + } + + /** + * Return all report filter instances + * + * @return filter_base[] + */ + final public function get_filter_instances(): array { + return array_map(static function(filter $filter): filter_base { + /** @var filter_base $filterclass */ + $filterclass = $filter->get_filter_class(); + + return $filterclass::create($filter); + }, $this->get_filters()); + } + + /** + * Set the filter values of the report + * + * @param array $values + * @return bool + */ + final public function set_filter_values(array $values): bool { + return user_filter_manager::set($this->report->get('id'), $values); + } + + /** + * Get the filter values of the report + * + * @return array + */ + final public function get_filter_values(): array { + return user_filter_manager::get($this->report->get('id')); + } + + /** + * Set if the report can be downloaded. + * + * @param bool $downloadable + * @param string $downloadfilename If the report is downloadable, then a filename should be provided here + */ + final public function set_downloadable(bool $downloadable, string $downloadfilename = 'export'): void { + $this->downloadable = $downloadable; + $this->downloadfilename = $downloadfilename; + } + + /** + * Get if the report can be downloaded. + * + * @return bool + */ + final public function is_downloadable(): bool { + return $this->downloadable; + } + + /** + * Return the downloadable report filename + * + * @return string + */ + final public function get_downloadfilename(): string { + return $this->downloadfilename; + } + + /** + * Returns the report context + * + * @return context + */ + public function get_context(): context { + return $this->report->get_context(); + } +} diff --git a/reportbuilder/classes/local/report/column.php b/reportbuilder/classes/local/report/column.php new file mode 100644 index 00000000000..af1d5fb477e --- /dev/null +++ b/reportbuilder/classes/local/report/column.php @@ -0,0 +1,544 @@ +. + +declare(strict_types=1); + +namespace core_reportbuilder\local\report; + +use coding_exception; +use lang_string; +use core_reportbuilder\local\helpers\database; + +/** + * Class to represent a report column + * + * @package core_reportbuilder + * @copyright 2020 Paul Holden + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +final class column { + + /** @var int Column type is integer */ + public const TYPE_INTEGER = 1; + + /** @var int Column type is text */ + public const TYPE_TEXT = 2; + + /** @var int Column type is timestamp */ + public const TYPE_TIMESTAMP = 3; + + /** @var int Column type is boolean */ + public const TYPE_BOOLEAN = 4; + + /** @var int Column type is float */ + public const TYPE_FLOAT = 5; + + /** @var int Column type is long text */ + public const TYPE_LONGTEXT = 6; + + /** @var int $index Column index within a report */ + private $index; + + /** @var string $columnname Internal reference to name of column */ + private $columnname; + + /** @var lang_string $columntitle Used as a title for the column in reports */ + private $columntitle; + + /** @var string $entityname Name of the entity this column belongs to */ + private $entityname; + + /** @var int $type Column data type (one of the TYPE_* class constants) */ + private $type = null; + + /** @var string[] $joins List of SQL joins for this column */ + private $joins = []; + + /** @var array $fields */ + private $fields = []; + + /** @var array $params */ + private $params = []; + + /** @var array[] $callbacks Array of [callable, additionalarguments] */ + private $callbacks = []; + + /** @var bool $issortable Used to indicate if a column is sortable */ + private $issortable = false; + + /** @var array $attributes */ + private $attributes = []; + + /** @var bool $available Used to know if column is available to the current user or not */ + protected $available = true; + + /** + * Column constructor + * + * For better readability use chainable methods, for example: + * + * $report->add_column( + * (new column('name', new lang_string('name'), 'user')) + * ->add_join('left join {table} t on t.id = p.tableid') + * ->add_field('t.name') + * ->add_callback([format::class, 'format_string'])); + * + * @param string $name Internal name of the column + * @param lang_string|null $title Title of the column used in reports (null for blank) + * @param string $entityname Name of the entity this column belongs to. Typically when creating columns within entities + * this value should be the result of calling {@see get_entity_name}, however if creating columns inside reports directly + * it should be the name of the entity as passed to {@see \core_reportbuilder\local\report\base::annotate_entity} + */ + public function __construct(string $name, ?lang_string $title, string $entityname) { + $this->columnname = $name; + $this->columntitle = $title; + $this->entityname = $entityname; + } + + /** + * Set column name + * + * @param string $name + * @return self + */ + public function set_name(string $name): self { + $this->columnname = $name; + return $this; + } + + /** + * Return column name + * + * @return mixed + */ + public function get_name(): string { + return $this->columnname; + } + + /** + * Set column title + * + * @param lang_string|null $title + * @return self + */ + public function set_title(?lang_string $title): self { + $this->columntitle = $title; + return $this; + } + + /** + * Return column title + * + * @return string + */ + public function get_title(): string { + return $this->columntitle ? (string) $this->columntitle : ''; + } + + /** + * Get column entity name + * + * @return string + */ + public function get_entity_name(): string { + return $this->entityname; + } + + + /** + * Return unique identifier for this column + * + * @return string + */ + public function get_unique_identifier(): string { + return $this->get_entity_name() . ':' . $this->get_name(); + } + + /** + * Set the column index within the current report + * + * @param int $index + * @return column + */ + public function set_index(int $index): self { + $this->index = $index; + return $this; + } + + /** + * Set the column type + * + * @param int $type + * @return column + * @throws coding_exception + */ + public function set_type(int $type): self { + $allowedtypes = [ + self::TYPE_INTEGER, + self::TYPE_TEXT, + self::TYPE_TIMESTAMP, + self::TYPE_BOOLEAN, + self::TYPE_FLOAT, + self::TYPE_LONGTEXT, + ]; + if (!in_array($type, $allowedtypes)) { + throw new coding_exception('Invalid column type', $type); + } + + $this->type = $type; + return $this; + } + + /** + * Return column type + * + * @return int|null + */ + public function get_type(): ?int { + return $this->type; + } + + /** + * Add join clause required for this column to join to existing tables/entities + * + * This is necessary in the case where {@see add_field} is selecting data from a table that isn't otherwise queried + * + * @param string $join + * @return self + */ + public function add_join(string $join): self { + $this->joins[trim($join)] = trim($join); + return $this; + } + + /** + * Add multiple join clauses required for this column, passing each to {@see add_join} + * + * Typically when defining columns in entities, you should pass {@see \core_reportbuilder\local\report\base::get_joins} to + * this method, so that all entity joins are included in the report when your column is added to it + * + * @param string[] $joins + * @return self + */ + public function add_joins(array $joins): self { + foreach ($joins as $join) { + $this->add_join($join); + } + return $this; + } + + /** + * Return column joins + * + * @return string[] + */ + public function get_joins(): array { + return array_values($this->joins); + } + + /** + * Adds a field to be queried from the database that is necessary for this column + * + * Multiple fields can be added per column, this method may be called several times. Field aliases must be unique inside + * any given column, but there will be no conflicts if the same aliases are used in other columns in the same report + * + * @param string $sql SQL query, this may be a simple "tablealias.fieldname" or a complex sub-query that returns only one field + * @param string $alias + * @param array $params + * @return self + * @throws coding_exception + */ + public function add_field(string $sql, string $alias = '', array $params = []): self { + database::validate_params($params); + + // SQL ends with a space and a word - this looks like an alias was passed as part of the field. + if (preg_match('/ \w+$/', $sql) && empty($alias)) { + throw new coding_exception('Column alias must be passed as a separate argument', $sql); + } + + // If no alias was specified, auto-detect it based on common patterns ("table.column" or just "column"). + if (empty($alias) && preg_match('/^(\w+\.)?(?\w+)$/', $sql, $matches)) { + $alias = $matches['fieldname']; + } + + if (empty($alias)) { + throw new coding_exception('Complex columns must have an alias', $sql); + } + + $this->fields[$alias] = $sql; + $this->params += $params; + + return $this; + } + + /** + * Add a list of comma-separated fields + * + * @param string $sql + * @param array $params + * @return self + */ + public function add_fields(string $sql, array $params = []): self { + database::validate_params($params); + + // Split SQL into separate fields (separated by comma). + $fields = preg_split('/\s*,\s*/', $sql); + foreach ($fields as $field) { + // Split each field into expression, where "as" and "alias" are optional. + $fieldparts = preg_split('/\s+/', $field); + + if (count($fieldparts) == 2 || (count($fieldparts) == 3 && strtolower($fieldparts[1]) === 'as')) { + $sql = reset($fieldparts); + $alias = array_pop($fieldparts); + $this->add_field($sql, $alias); + } else { + $this->add_field($field); + } + } + + $this->params += $params; + + return $this; + } + + /** + * Given a param name, add a unique prefix to ensure that the same column with params can be added multiple times to a report + * + * @param string $name + * @return string + */ + private function unique_param_name(string $name): string { + return "p{$this->index}_{$name}"; + } + + /** + * Helper method to take all fields added to the column, and return appropriate SQL and alias + * + * @return array[] + */ + private function get_fields_sql_alias(): array { + $fields = []; + + foreach ($this->fields as $alias => $sql) { + // Ensure params within SQL are prefixed with column index. + foreach ($this->params as $name => $value) { + $sql = preg_replace_callback('/:(?' . preg_quote($name, '\b/') . ')/', function(array $matches): string { + return ':' . $this->unique_param_name($matches['param']); + }, $sql); + } + + $fields[$alias] = [ + 'sql' => $sql, + 'alias' => substr("c{$this->index}_{$alias}", 0, 30), + ]; + } + + return $fields; + } + + /** + * Return array of SQL expressions for each field of this column + * + * @return array + */ + public function get_fields(): array { + $fields = array_map(static function(array $field): string { + return "{$field['sql']} AS {$field['alias']}"; + }, $this->get_fields_sql_alias()); + + return array_values($fields); + } + + /** + * Return column parameters, prefixed by the current index to allow the column to be added multiple times to a report + * + * @return array + */ + public function get_params(): array { + $params = []; + + foreach ($this->params as $name => $value) { + $paramname = $this->unique_param_name($name); + $params[$paramname] = $value; + } + + return $params; + } + + /** + * Return an alias for this column (the generated alias of it's first field) + * + * @return string + * @throws coding_exception + */ + public function get_column_alias(): string { + if (!$fields = $this->get_fields_sql_alias()) { + throw new coding_exception('Column ' . $this->get_unique_identifier() . ' contains no fields'); + } + + return reset($fields)['alias']; + } + + /** + * Adds column callback (in the case there are multiple, they will be applied one after another) + * + * The callback should implement the following signature (where $value is the first column field, $row is all column + * fields, and $additionalarguments are those passed on from this method): + * + * function($value, stdClass $row[, $additionalarguments]): string + * + * @param callable $callable function that takes arguments ($value, \stdClass $row, $additionalarguments) + * @param mixed $additionalarguments + * @return self + */ + public function add_callback(callable $callable, $additionalarguments = null): self { + $this->callbacks[] = [$callable, $additionalarguments]; + return $this; + } + + /** + * Sets column callback. This will overwrite any previously added callbacks {@see add_callback} + * + * @param callable $callable + * @param mixed $additionalarguments + * @return self + */ + public function set_callback(callable $callable, $additionalarguments = null): self { + $this->callbacks = []; + return $this->add_callback($callable, $additionalarguments); + } + + /** + * Sets the column as sortable + * + * @param bool $issortable + * @return self + */ + public function set_is_sortable(bool $issortable): self { + $this->issortable = $issortable; + return $this; + } + + /** + * Return sortable status of column + * + * @return bool + */ + public function get_is_sortable(): bool { + return $this->issortable; + } + + /** + * Extract all values from given row for this column + * + * @param array $row + * @return array + */ + private function get_values(array $row): array { + $values = []; + + foreach ($this->get_fields_sql_alias() as $alias => $field) { + $values[$alias] = $row[$field['alias']]; + } + + return $values; + } + + /** + * Return the default column value, that being the value of it's first field + * + * @param array $values + * @return mixed + */ + private function get_default_value(array $values) { + $value = reset($values); + + // Ensure default value is cast to it's strict type. + switch ($this->get_type()) { + case self::TYPE_INTEGER: + case self::TYPE_TIMESTAMP: + $value = (int) $value; + break; + case self::TYPE_FLOAT: + $value = (float) $value; + break; + case self::TYPE_BOOLEAN: + $value = (bool) $value; + break; + } + + return $value; + } + + /** + * Return column value based on complete table row + * + * @param array $row + * @return mixed + */ + public function format_value(array $row) { + $values = $this->get_values($row); + $value = $this->get_default_value($values); + + // Loop through, and apply any defined callbacks. + foreach ($this->callbacks as $callback) { + $value = ($callback[0])($value, (object) $values, $callback[1]); + } + + return $value; + } + + /** + * Add column attributes (data-, class, etc.) that will be included in HTML when column is displayed + * + * @param array $attributes + * @return self + */ + public function add_attributes(array $attributes): self { + $this->attributes = $attributes + $this->attributes; + return $this; + } + + /** + * Returns the column HTML attributes + * + * @return array + */ + public function get_attributes(): array { + return $this->attributes; + } + + /** + * Return available state of the column for the current user. For instance the column may be added to a report with the + * expectation that only some users are able to see it + * + * @return bool + */ + public function get_is_available(): bool { + return $this->available; + } + + /** + * Conditionally set whether the column is available. + * + * @param bool $available + * @return self + */ + public function set_is_available(bool $available): self { + $this->available = $available; + return $this; + } +} diff --git a/reportbuilder/classes/local/report/filter.php b/reportbuilder/classes/local/report/filter.php new file mode 100644 index 00000000000..aa7757b96af --- /dev/null +++ b/reportbuilder/classes/local/report/filter.php @@ -0,0 +1,310 @@ +. + +declare(strict_types=1); + +namespace core_reportbuilder\local\report; + +use lang_string; +use moodle_exception; +use core_reportbuilder\local\filters\base; + +/** + * Class to represent a report filter + * + * @package core_reportbuilder + * @copyright 2021 Paul Holden + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +final class filter { + + /** @var string $filterclass */ + private $filterclass; + + /** @var string $name */ + private $name; + + /** @var lang_string $header */ + private $header; + + /** @var string $entity */ + private $entityname; + + /** @var string $fieldsql */ + private $fieldsql = ''; + + /** @var array $fieldparams */ + private $fieldparams = []; + + /** @var string[] $joins */ + protected $joins = []; + + /** @var bool $available */ + protected $available = true; + + /** @var mixed $options */ + protected $options; + + /** @var array $limitoperators */ + protected $limitoperators = []; + + /** + * Filter constructor + * + * @param string $filterclass Filter type class to use, must extend {@see base} filter class + * @param string $name Internal name of the filter + * @param lang_string $header Title of the filter used in reports + * @param string $entityname Name of the entity this filter belongs to. Typically when creating filters within entities + * this value should be the result of calling {@see get_entity_name}, however if creating filters inside reports directly + * it should be the name of the entity as passed to {@see \core_reportbuilder\local\report\base::annotate_entity} + * @param string $fieldsql SQL clause to use for filtering, {@see set_field_sql} + * @param array $fieldparams + * @throws moodle_exception For invalid filter class + */ + public function __construct( + string $filterclass, + string $name, + lang_string $header, + string $entityname, + string $fieldsql = '', + array $fieldparams = [] + ) { + if (!class_exists($filterclass) || !is_subclass_of($filterclass, base::class)) { + throw new moodle_exception('filterinvalid', 'reportbuilder', '', null, $filterclass); + } + + $this->filterclass = $filterclass; + $this->name = $name; + $this->header = $header; + $this->entityname = $entityname; + + if ($fieldsql !== '') { + $this->set_field_sql($fieldsql, $fieldparams); + } + } + + /** + * Get filter class path + * + * @return string + */ + public function get_filter_class(): string { + return $this->filterclass; + } + + /** + * Get filter name + * + * @return string + */ + public function get_name(): string { + return $this->name; + } + + /** + * Return header + * + * @return string + */ + public function get_header(): string { + return $this->header->out(); + } + + /** + * Set header + * + * @param lang_string $header + * @return self + */ + public function set_header(lang_string $header): self { + $this->header = $header; + return $this; + } + + /** + * Return filter entity name + * + * @return string + */ + public function get_entity_name(): string { + return $this->entityname; + } + + /** + * Return unique identifier for this filter + * + * @return string + */ + public function get_unique_identifier(): string { + return $this->get_entity_name() . ':' . $this->get_name(); + } + + /** + * Return joins + * + * @return string[] + */ + public function get_joins(): array { + return array_values($this->joins); + } + + /** + * Add join clause required for this filter to join to existing tables/entities + * + * This is necessary in the case where {@see set_field_sql} is selecting data from a table that isn't otherwise queried + * + * @param string $join + * @return self + */ + public function add_join(string $join): self { + $this->joins[trim($join)] = trim($join); + return $this; + } + + /** + * Add multiple join clauses required for this filter, passing each to {@see add_join} + * + * Typically when defining filters in entities, you should pass {@see \core_reportbuilder\local\report\base::get_joins} to + * this method, so that all entity joins are included in the report when your filter is used in it + * + * @param string[] $joins + * @return self + */ + public function add_joins(array $joins): self { + foreach ($joins as $join) { + $this->add_join($join); + } + return $this; + } + + /** + * Get SQL expression for the field + * + * @return string + */ + public function get_field_sql(): string { + return $this->fieldsql; + } + + /** + * Get the SQL params for the field being filtered + * + * @return array + */ + public function get_field_params(): array { + return $this->fieldparams; + } + + /** + * Set the SQL expression for the field that is being filtered. It will be passed to the filter class + * + * @param string $sql + * @param array $params + * @return self + */ + public function set_field_sql(string $sql, array $params = []): self { + $this->fieldsql = $sql; + $this->fieldparams = $params; + return $this; + } + + /** + * Return available state of the filter for the current user + * + * @return bool + */ + public function get_is_available(): bool { + return $this->available; + } + + /** + * Conditionally set whether the filter is available. For instance the filter may be added to a report with the + * expectation that only some users are able to see it + * + * @param bool $available + * @return self + */ + public function set_is_available(bool $available): self { + $this->available = $available; + return $this; + } + + /** + * Set the options for the filter in the format that the filter class expected (e.g. the "select" filter expects an array) + * + * This method should only be used if the options do not require any calculations/queries, in which + * case {@see set_options_callback} should be used. For performance, {@see get_string} shouldn't be used either, use of + * {@see lang_string} is instead encouraged + * + * @param mixed $options + * @return self + */ + public function set_options($options): self { + $this->options = $options; + return $this; + } + + /** + * Set the options for the filter to be returned by a callback (that recieves no arguments) in the format that the filter + * class expects + * + * @param callable $callback + * @return self + */ + public function set_options_callback(callable $callback): self { + $this->options = $callback; + return $this; + } + + /** + * Get the options for the filter, returning via the the previously set options or generated via defined options callback + * + * @return mixed + */ + public function get_options() { + if (is_callable($this->options)) { + $callable = $this->options; + $this->options = ($callable)(); + } + return $this->options; + } + + /** + * Set a limited subset of operators that should be used for the filter, refer to each filter class to find defined + * operator constants + * + * @param array $limitoperators Simple array of operator values + * @return self + */ + public function set_limited_operators(array $limitoperators): self { + $this->limitoperators = $limitoperators; + return $this; + } + + /** + * Filter given operators to include only those previously defined by {@see set_limited_operators} + * + * @param array $operators All operators as defined by the filter class + * @return array + */ + public function restrict_limited_operators(array $operators): array { + if (empty($this->limitoperators)) { + return $operators; + } + + return array_intersect_key($operators, array_flip($this->limitoperators)); + } +} diff --git a/reportbuilder/classes/manager.php b/reportbuilder/classes/manager.php new file mode 100644 index 00000000000..550e7f3153a --- /dev/null +++ b/reportbuilder/classes/manager.php @@ -0,0 +1,87 @@ +. + +declare(strict_types=1); + +namespace core_reportbuilder; + +use stdClass; +use core_reportbuilder\local\models\report; +use core_reportbuilder\local\report\base; + +/** + * Report management class + * + * @package core_reportbuilder + * @copyright 2020 Paul Holden + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class manager { + + /** + * Return an instance of a report class from the given report persistent + * + * @param report $report + * @param array $parameters + * @return base + * @throws source_invalid_exception + * @throws source_unavailable_exception + */ + public static function get_report_from_persistent(report $report, array $parameters = []): base { + $source = $report->get('source'); + + // Throw exception for invalid or unavailable report source. + if (!self::report_source_exists($source)) { + throw new source_invalid_exception($source); + } else if (!self::report_source_available($source)) { + throw new source_unavailable_exception($source); + } + + return new $source($report, $parameters); + } + + /** + * Verify that report source exists and extends appropriate base classes + * + * @param string $source Full namespaced path to report definition + * @param string $additionalbaseclass Specify addition base class that given classname should extend + * @return bool + */ + public static function report_source_exists(string $source, string $additionalbaseclass = ''): bool { + return (class_exists($source) && is_subclass_of($source, base::class) && + (empty($additionalbaseclass) || is_subclass_of($source, $additionalbaseclass))); + } + + /** + * Verify given report source is available. Note that it is assumed caller has already checked that it exists + * + * @param string $source + * @return bool + */ + public static function report_source_available(string $source): bool { + return call_user_func([$source, 'is_available']); + } + + /** + * Create new report persistent + * + * @param stdClass $reportdata + * @return report + */ + public static function create_report_persistent(stdClass $reportdata): report { + return (new report(0, $reportdata))->create(); + } +} diff --git a/reportbuilder/classes/output/renderer.php b/reportbuilder/classes/output/renderer.php new file mode 100644 index 00000000000..b9cdb45873f --- /dev/null +++ b/reportbuilder/classes/output/renderer.php @@ -0,0 +1,59 @@ +. + +declare(strict_types=1); + +namespace core_reportbuilder\output; + +use plugin_renderer_base; +use core_reportbuilder\table\system_report_table; + +/** + * Report renderer class + * + * @package core_reportbuilder + * @copyright 2020 Paul Holden + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class renderer extends plugin_renderer_base { + + /** + * Render a system report + * + * @param system_report $report + * @return string + */ + protected function render_system_report(system_report $report): string { + $context = $report->export_for_template($this); + + return $this->render_from_template('core_reportbuilder/system_report', $context); + } + + /** + * Render a system report table + * + * @param system_report_table $table + * @return string + */ + protected function render_system_report_table(system_report_table $table): string { + ob_start(); + $table->out($table->get_default_per_page(), false); + $output = ob_get_contents(); + ob_end_clean(); + + return $output; + } +} diff --git a/reportbuilder/classes/output/system_report.php b/reportbuilder/classes/output/system_report.php new file mode 100644 index 00000000000..f1468a14e4a --- /dev/null +++ b/reportbuilder/classes/output/system_report.php @@ -0,0 +1,74 @@ +. + +declare(strict_types=1); + +namespace core_reportbuilder\output; + +use renderable; +use renderer_base; +use stdClass; +use templatable; +use core_reportbuilder\external\system_report_exporter; +use core_reportbuilder\local\models\report; +use core_reportbuilder\local\report\base; + +/** + * System report output class + * + * @package core_reportbuilder + * @copyright 2020 Paul Holden + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class system_report implements renderable, templatable { + + /** @var report $report */ + protected $report; + + /** @var base $source */ + protected $source; + + /** @var array $parameters */ + protected $parameters; + + /** + * Class constructor + * + * @param report $report + * @param base $source + * @param array $parameters + */ + public function __construct(report $report, base $source, array $parameters) { + $this->report = $report; + $this->source = $source; + $this->parameters = $parameters; + } + + /** + * Export report data suitable for a template + * + * @param renderer_base $output + * @return stdClass + */ + public function export_for_template(renderer_base $output): stdClass { + $exporter = new system_report_exporter($this->report, [ + 'source' => $this->source, + 'parameters' => json_encode($this->parameters), + ]); + + return $exporter->export($output); + } +} diff --git a/reportbuilder/classes/privacy/provider.php b/reportbuilder/classes/privacy/provider.php new file mode 100644 index 00000000000..a260154e716 --- /dev/null +++ b/reportbuilder/classes/privacy/provider.php @@ -0,0 +1,72 @@ +. + +declare(strict_types=1); + +namespace core_reportbuilder\privacy; + +use core_privacy\local\metadata\collection; +use core_privacy\local\request\writer; +use core_reportbuilder\local\helpers\user_filter_manager; +use core_reportbuilder\local\models\report; + +/** + * Privacy Subsystem for core_reportbuilder + * + * @package core_reportbuilder + * @copyright 2021 David Matamoros + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class provider implements + \core_privacy\local\metadata\provider, + \core_privacy\local\request\user_preference_provider { + + /** + * Returns metadata about the component + * + * @param collection $collection + * @return collection + */ + public static function get_metadata(collection $collection): collection { + $collection->add_database_table(report::TABLE, [ + 'name' => 'privacy:metadata:report:name', + 'usercreated' => 'privacy:metadata:report:usercreated', + 'usermodified' => 'privacy:metadata:report:usermodified', + ], 'privacy:metadata:report'); + + $collection->add_user_preference('core_reportbuilder', 'privacy:metadata:preference:reportfilter'); + + return $collection; + } + + /** + * Export all user preferences for the component + * + * @param int $userid + */ + public static function export_user_preferences(int $userid): void { + $preferencestring = get_string('privacy:metadata:preference:reportfilter', 'core_reportbuilder'); + + $filters = user_filter_manager::get_all_for_user($userid); + foreach ($filters as $key => $filter) { + writer::export_user_preference('core_reportbuilder', + $key, + json_encode($filter, JSON_PRETTY_PRINT), + $preferencestring + ); + } + } +} diff --git a/reportbuilder/classes/report_access_exception.php b/reportbuilder/classes/report_access_exception.php new file mode 100644 index 00000000000..3783747553f --- /dev/null +++ b/reportbuilder/classes/report_access_exception.php @@ -0,0 +1,38 @@ +. + +declare(strict_types=1); + +namespace core_reportbuilder; + +use moodle_exception; + +/** + * User cannot access report exception + * + * @package core_reportbuilder + * @copyright 2021 Paul Holden + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class report_access_exception extends moodle_exception { + + /** + * Constructor + */ + public function __construct() { + parent::__construct('errorreportaccess', 'reportbuilder'); + } +} diff --git a/reportbuilder/classes/source_invalid_exception.php b/reportbuilder/classes/source_invalid_exception.php new file mode 100644 index 00000000000..6edd067a1f4 --- /dev/null +++ b/reportbuilder/classes/source_invalid_exception.php @@ -0,0 +1,40 @@ +. + +declare(strict_types=1); + +namespace core_reportbuilder; + +use moodle_exception; + +/** + * Invalid report source exception + * + * @package core_reportbuilder + * @copyright 2020 Paul Holden + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class source_invalid_exception extends moodle_exception { + + /** + * Constructor + * + * @param string $source + */ + public function __construct(string $source) { + parent::__construct('errorsourceinvalid', 'reportbuilder', '', null, $source); + } +} diff --git a/reportbuilder/classes/source_unavailable_exception.php b/reportbuilder/classes/source_unavailable_exception.php new file mode 100644 index 00000000000..dc439a691be --- /dev/null +++ b/reportbuilder/classes/source_unavailable_exception.php @@ -0,0 +1,40 @@ +. + +declare(strict_types=1); + +namespace core_reportbuilder; + +use moodle_exception; + +/** + * Unavailable report source exception + * + * @package core_reportbuilder + * @copyright 2020 Paul Holden + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class source_unavailable_exception extends moodle_exception { + + /** + * Constructor + * + * @param string $source + */ + public function __construct(string $source) { + parent::__construct('errorsourceunavailable', 'reportbuilder', '', null, $source); + } +} diff --git a/reportbuilder/classes/system_report.php b/reportbuilder/classes/system_report.php new file mode 100644 index 00000000000..3c6c67c5269 --- /dev/null +++ b/reportbuilder/classes/system_report.php @@ -0,0 +1,282 @@ +. + +declare(strict_types=1); + +namespace core_reportbuilder; + +use coding_exception; +use stdClass; +use core_reportbuilder\local\models\report; +use core_reportbuilder\local\report\action; +use core_reportbuilder\local\report\base; +use core_reportbuilder\local\report\column; + +/** + * Base class for system reports + * + * @package core_reportbuilder + * @copyright 2020 Paul Holden + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +abstract class system_report extends base { + + /** @var array $parameters */ + private $parameters; + + /** @var string[] $basefields List of base fields */ + private $basefields = []; + + /** @var action[] $actions */ + private $actions = []; + + /** @var column $initialsortcolumn */ + private $initialsortcolumn; + + /** @var int $initialsortdirection */ + private $initialsortdirection; + + /** + * System report constructor. + * + * @param report $report + * @param array $parameters + */ + final public function __construct(report $report, array $parameters) { + $this->parameters = $parameters; + + parent::__construct($report); + } + + /** + * Validates access to view this report + * + * This is necessary to implement independently of the page that would typically embed the report because + * subsequent pages are requested via AJAX requests, and access should be validated each time + * + * @return bool + */ + abstract protected function can_view(): bool; + + /** + * Validate access to the report + * + * @throws report_access_exception + */ + final public function require_can_view(): void { + if (!$this->can_view()) { + throw new report_access_exception(); + } + } + + /** + * Report validation + * + * @throws report_access_exception If user cannot access the report + * @throws coding_exception If no default column are specified + */ + protected function validate(): void { + parent::validate(); + + $this->require_can_view(); + + // Ensure the report has some default columns specified. + if (empty($this->get_columns())) { + throw new coding_exception('No columns added'); + } + } + + /** + * Add list of fields that have to be always included in SQL query for actions and row classes + * + * Base fields are only available in system reports because they are not compatible with aggregation + * + * @param string $sql SQL clause for the list of fields that only uses main table or base joins + */ + final protected function add_base_fields(string $sql): void { + $this->basefields[] = $sql; + } + + /** + * Return report base fields + * + * @return array + */ + final public function get_base_fields(): array { + return $this->basefields; + } + + /** + * Adds an action to the report + * + * @param action $action + */ + final public function add_action(action $action): void { + $this->actions[] = $action; + } + + /** + * Whether report has any actions + * + * @return bool + */ + final public function has_actions(): bool { + return !empty($this->actions); + } + + /** + * Return report actions + * + * @return action[] + */ + final public function get_actions(): array { + return $this->actions; + } + + /** + * Set all report parameters + * + * @param array $parameters + */ + final public function set_parameters(array $parameters): void { + $this->parameters = $parameters; + } + + /** + * Return all report parameters + * + * @return array + */ + final public function get_parameters(): array { + return $this->parameters; + } + + /** + * Return specific report parameter + * + * @param string $param + * @param mixed $default + * @param string $type + * @return mixed + */ + final public function get_parameter(string $param, $default, string $type) { + if (!array_key_exists($param, $this->parameters)) { + return $default; + } + + return clean_param($this->parameters[$param], $type); + } + + /** + * Output the report + * + * @uses \core_reportbuilder\output\renderer::render_system_report() + * + * @return string + */ + final public function output(): string { + global $PAGE; + + /** @var \core_reportbuilder\output\renderer $renderer */ + $renderer = $PAGE->get_renderer('core_reportbuilder'); + $report = new \core_reportbuilder\output\system_report($this->get_report_persistent(), $this, $this->parameters); + + return $renderer->render($report); + } + + /** + * CSS classes to add to the row. Can be overridden by system reports do define class to be added to output according to + * content of each row + * + * @param stdClass $row + * @return string + */ + public function get_row_class(stdClass $row): string { + return ''; + } + + /** + * Default 'per page' size. Can be overridden by system reports to define a different paging value + * + * @return int + */ + public function get_default_per_page(): int { + return self::DEFAULT_PAGESIZE; + } + + /** + * Called before rendering each row. Can be overridden to pre-fetch/create objects and store them in the class, which can + * later be used in column and action callbacks + * + * @param stdClass $row + */ + public function row_callback(stdClass $row): void { + return; + } + + /** + * Validates access to download this report. + * + * @return bool + */ + final public function can_be_downloaded(): bool { + return $this->can_view() && $this->is_downloadable(); + } + + /** + * Return list of column names that will be excluded when table is downloaded. Extending classes should override this method + * as appropriate + * + * @return string[] Array of column unique identifiers + */ + public function get_exclude_columns_for_download(): array { + return []; + } + + /** + * Set initial sort column and sort direction for the report + * + * @param string $uniqueidentifier + * @param int $sortdirection One of SORT_ASC or SORT_DESC + * @throws coding_exception + */ + public function set_initial_sort_column(string $uniqueidentifier, int $sortdirection): void { + if (!$sortcolumn = $this->get_column($uniqueidentifier)) { + throw new coding_exception('Unknown column identifier', $uniqueidentifier); + } + + $this->initialsortcolumn = $sortcolumn; + $this->initialsortdirection = $sortdirection; + } + + /** + * Get initial sort column + * + * @return column|null + */ + public function get_initial_sort_column(): ?column { + return $this->initialsortcolumn; + } + + /** + * Get initial sort column direction + * + * @return int + */ + public function get_initial_sort_direction(): int { + return $this->initialsortdirection; + } +} diff --git a/reportbuilder/classes/system_report_factory.php b/reportbuilder/classes/system_report_factory.php new file mode 100644 index 00000000000..36cbb4a15a0 --- /dev/null +++ b/reportbuilder/classes/system_report_factory.php @@ -0,0 +1,87 @@ +. + +declare(strict_types=1); + +namespace core_reportbuilder; + +use context; +use core_reportbuilder\local\models\report; +use core_reportbuilder\local\report\base; + +/** + * Factory class for creating system report instances + * + * @package core_reportbuilder + * @copyright 2020 Paul Holden + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class system_report_factory { + + /** + * Create and return instance of given system report source + * + * @param string $source Class path of system report definition + * @param context $context + * @param string $component + * @param string $area + * @param int $itemid + * @param array $parameters Simple key/value pairs, accessed inside reports using $this->get_parameter() + * @return system_report + * @throws source_invalid_exception + */ + public static function create(string $source, context $context, string $component = '', string $area = '', + int $itemid = 0, array $parameters = []): system_report { + + // Exit early if source isn't a system report. + if (!manager::report_source_exists($source, system_report::class)) { + throw new source_invalid_exception($source); + } + + $report = static::get_report_persistent($source, $context, $component, $area, $itemid); + + return manager::get_report_from_persistent($report, $parameters); + } + + /** + * Given a report source, with accompanying context information, return a persistent report instance + * + * @param string $source + * @param context $context + * @param string $component + * @param string $area + * @param int $itemid + * @return report + */ + private static function get_report_persistent(string $source, context $context, string $component = '', string $area = '', + int $itemid = 0): report { + + $reportdata = [ + 'type' => base::TYPE_SYSTEM_REPORT, + 'source' => $source, + 'contextid' => $context->id, + 'component' => $component, + 'area' => $area, + 'itemid' => $itemid, + ]; + + if ($report = report::get_record($reportdata)) { + return $report; + } + + return manager::create_report_persistent((object) $reportdata); + } +} diff --git a/reportbuilder/classes/table/system_report_table.php b/reportbuilder/classes/table/system_report_table.php new file mode 100644 index 00000000000..705363509d0 --- /dev/null +++ b/reportbuilder/classes/table/system_report_table.php @@ -0,0 +1,329 @@ +. + +declare(strict_types=1); + +namespace core_reportbuilder\table; + +use context; +use html_writer; +use moodle_exception; +use moodle_url; +use renderable; +use table_sql; +use stdClass; +use core_table\dynamic; +use core_reportbuilder\manager; +use core_reportbuilder\system_report; +use core_reportbuilder\local\filters\base; +use core_reportbuilder\local\models\report; +use core_reportbuilder\local\report\action; +use core_reportbuilder\local\report\column; +use core_table\local\filter\filterset; + +defined('MOODLE_INTERNAL') || die; + +require_once("{$CFG->libdir}/tablelib.php"); + +/** + * System report dynamic table class + * + * @package core_reportbuilder + * @copyright 2020 Paul Holden + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class system_report_table extends table_sql implements dynamic, renderable { + + /** @var string Unique ID prefix for the table */ + private const UNIQUEID_PREFIX = 'system-report-table-'; + + /** @var report $report */ + protected $report; + + /** @var system_report $systemreport */ + protected $systemreport; + + /** + * Table constructor. Note that the passed unique ID value must match the pattern "system-report-table-(\d+)" so that + * dynamic updates continue to load the same report + * + * @param string $uniqueid + * @param array $parameters + * @throws moodle_exception For invalid unique ID + */ + public function __construct(string $uniqueid, array $parameters = []) { + if (!preg_match('/^' . self::UNIQUEID_PREFIX . '(?\d+)$/', $uniqueid, $matches)) { + throw new moodle_exception('invalidsystemreportid', 'core_reportbuilder', '', null, $uniqueid); + } + + parent::__construct($uniqueid); + + // Load the report persistent, and accompanying system report instance. + $this->report = new report($matches['id']); + $this->systemreport = manager::get_report_from_persistent($this->report, $parameters); + + $fields = $this->systemreport->get_base_fields(); + $maintable = $this->systemreport->get_main_table(); + $maintablealias = $this->systemreport->get_main_table_alias(); + $joins = $this->systemreport->get_joins(); + [$where, $params] = $this->systemreport->get_base_condition(); + + $this->set_attribute('data-region', 'reportbuilder-table'); + $this->set_attribute('class', $this->attributes['class'] . ' reportbuilder-table'); + + // Download options. + $this->showdownloadbuttonsat = [TABLE_P_BOTTOM]; + $this->is_downloading($parameters['download'] ?? null, $this->systemreport->get_downloadfilename()); + + // Retrieve all report columns. If we are downloading the report, remove as required. + $columns = $this->systemreport->get_columns(); + if ($this->is_downloading()) { + $columns = array_diff_key($columns, + array_flip($this->systemreport->get_exclude_columns_for_download())); + } + + $columnheaders = []; + $columnindex = 1; + foreach ($columns as $identifier => $column) { + $column->set_index($columnindex++); + + $columnheaders[$column->get_column_alias()] = $column->get_title(); + + // Add each columns fields, joins and params to our report. + $fields = array_merge($fields, $column->get_fields()); + $joins = array_merge($joins, $column->get_joins()); + $params = array_merge($params, $column->get_params()); + + // Disable sorting for some columns. + if (!$column->get_is_sortable()) { + $this->no_sorting($column->get_column_alias()); + } + } + + // If the report has any actions then append appropriate column, note that actions are excluded during download. + if ($this->systemreport->has_actions() && !$this->is_downloading()) { + $columnheaders['actions'] = html_writer::tag('span', get_string('actions', 'core_reportbuilder'), [ + 'class' => 'sr-only', + ]); + $this->no_sorting('actions'); + } + + $this->define_columns(array_keys($columnheaders)); + $this->define_headers(array_values($columnheaders)); + + // Initial table sort column. + if ($sortcolumn = $this->systemreport->get_initial_sort_column()) { + $this->sortable(true, $sortcolumn->get_column_alias(), $this->systemreport->get_initial_sort_direction()); + } + + // Table configuration. + $this->initialbars(false); + $this->collapsible(false); + $this->pageable(true); + $this->set_default_per_page($this->systemreport->get_default_per_page()); + + // Initialise table SQL properties. + $fieldsql = implode(', ', $fields); + $this->init_sql($fieldsql, "{{$maintable}} {$maintablealias}", $joins, $where, $params); + } + + /** + * Return a new instance of the class for given report ID. We include report parameters here so they are present during + * initialisation + * + * @param int $reportid + * @param array $parameters + * @return static + */ + public static function create(int $reportid, array $parameters): self { + return new static(self::UNIQUEID_PREFIX . $reportid, $parameters); + } + + /** + * Set the filterset in the table class. We set the report parameters here so that they are persisted while paging + * + * @param filterset $filterset + */ + public function set_filterset(filterset $filterset): void { + $parameters = $filterset->get_filter('parameters')->current(); + $this->systemreport->set_parameters((array) json_decode($parameters, true)); + + parent::set_filterset($filterset); + } + + /** + * Initialises table SQL properties + * + * @param string $fields + * @param string $from + * @param array $joins + * @param string $where + * @param array $params + */ + protected function init_sql(string $fields, string $from, array $joins, string $where, array $params): void { + $wheres = []; + if ($where !== '') { + $wheres[] = $where; + } + + $filtervalues = $this->systemreport->get_filter_values(); + foreach ($this->systemreport->get_filters() as $filter) { + /** @var base $filterclass */ + $filterclass = $filter->get_filter_class(); + $filterinstance = $filterclass::create($filter); + + [$filtersql, $filterparams] = $filterinstance->get_sql_filter($filtervalues); + if ($filtersql !== '') { + $wheres[] = "({$filtersql})"; + $params = array_merge($params, $filterparams); + + $joins = array_merge($joins, $filter->get_joins()); + } + } + + $wheresql = '1=1'; + if (!empty($wheres)) { + $wheresql = implode(' AND ', $wheres); + } + + // Add unique table joins. + $from .= ' ' . implode(' ', array_unique($joins)); + + $this->set_sql($fields, $from, $wheresql, $params); + $this->set_count_sql("SELECT COUNT(1) FROM {$from} WHERE {$wheresql}", $params); + } + + /** + * Override parent method of the same, to make use of a recordset and avoid issues with duplicate values in the first column + * + * @param int $pagesize + * @param bool $useinitialsbar + */ + public function query_db($pagesize, $useinitialsbar = true) { + global $DB; + + $sql = "SELECT {$this->sql->fields} FROM {$this->sql->from} WHERE {$this->sql->where}"; + + $sort = $this->get_sql_sort(); + if ($sort) { + $sql .= " ORDER BY {$sort}"; + } + + if (!$this->is_downloading()) { + $this->pagesize($pagesize, $DB->count_records_sql($this->countsql, $this->countparams)); + + $this->rawdata = $DB->get_recordset_sql($sql, $this->sql->params, $this->get_page_start(), $this->get_page_size()); + } else { + $this->rawdata = $DB->get_recordset_sql($sql, $this->sql->params); + } + } + + /** + * Override parent method for retrieving row class with that defined by the system report + * + * @param array|stdClass $row + * @return string + */ + public function get_row_class($row) { + return $this->systemreport->get_row_class((object) $row); + } + + /** + * Format each row of returned data, executing defined callbacks for the row and each column + * + * @param array|stdClass $row + * @return array + */ + public function format_row($row) { + $this->systemreport->row_callback((object) $row); + + /** @var column[] $columnsbyalias */ + $columnsbyalias = []; + + // Create a lookup for convenience, indexed by column alias. + $columns = $this->systemreport->get_columns(); + foreach ($columns as $column) { + $columnsbyalias[$column->get_column_alias()] = $column; + } + + // Walk over the row, and for any key that matches one of our column aliases, call that columns format method. + $row = (array) $row; + array_walk($row, static function(&$value, $key) use ($row, $columnsbyalias): void { + if (array_key_exists($key, $columnsbyalias)) { + $value = $columnsbyalias[$key]->format_value($row); + } + }); + + if ($this->systemreport->has_actions()) { + $row['actions'] = $this->format_row_actions((object) $row); + } + + return $row; + } + + /** + * Return formatted actions column for the row + * + * @param stdClass $row + * @return string + */ + private function format_row_actions(stdClass $row): string { + $actions = array_map(static function(action $action) use ($row): string { + return (string) $action->get_action_link($row); + }, $this->systemreport->get_actions()); + + return implode('', $actions); + } + + /** + * Get the context for the table (that of the report persistent) + * + * @return context + */ + public function get_context(): context { + return $this->report->get_context(); + } + + /** + * Set the base URL of the table to the current page URL + */ + public function guess_base_url(): void { + $this->baseurl = new moodle_url('/'); + } + + /** + * Get the html for the download buttons + * + * @return string + */ + public function download_buttons(): string { + global $OUTPUT; + + if ($this->systemreport->can_be_downloaded() && !$this->is_downloading()) { + return $OUTPUT->download_dataformat_selector( + get_string('downloadas', 'table'), + new \moodle_url('/reportbuilder/download.php'), + 'download', + [ + 'id' => $this->report->get('id'), + 'parameters' => json_encode($this->systemreport->get_parameters()), + ] + ); + } + + return ''; + } +} diff --git a/reportbuilder/classes/table/system_report_table_filterset.php b/reportbuilder/classes/table/system_report_table_filterset.php new file mode 100644 index 00000000000..a67348182f8 --- /dev/null +++ b/reportbuilder/classes/table/system_report_table_filterset.php @@ -0,0 +1,42 @@ +. + +declare(strict_types=1); + +namespace core_reportbuilder\table; + +use core_table\local\filter\filterset; + +/** + * System report dynamic table filterset class + * + * @package core_reportbuilder + * @copyright 2020 Paul Holden + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class system_report_table_filterset extends filterset { + + /** + * Get the required filters + * + * @return array. + */ + public function get_required_filters(): array { + return [ + 'parameters' => system_report_table_parameters_filter::class, + ]; + } +} diff --git a/reportbuilder/classes/table/system_report_table_parameters_filter.php b/reportbuilder/classes/table/system_report_table_parameters_filter.php new file mode 100644 index 00000000000..5d934374d79 --- /dev/null +++ b/reportbuilder/classes/table/system_report_table_parameters_filter.php @@ -0,0 +1,32 @@ +. + +declare(strict_types=1); + +namespace core_reportbuilder\table; + +use core_table\local\filter\string_filter; + +/** + * Class for storing given system report parameters + * + * @package core_reportbuilder + * @copyright 2021 Paul Holden + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class system_report_table_parameters_filter extends string_filter { + +} diff --git a/reportbuilder/download.php b/reportbuilder/download.php new file mode 100644 index 00000000000..503960de3dc --- /dev/null +++ b/reportbuilder/download.php @@ -0,0 +1,55 @@ +. + +/** + * Download a report + * + * @package core_reportbuilder + * @copyright 2021 David Matamoros + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +declare(strict_types=1); + +use core_reportbuilder\system_report_factory; + +require_once(__DIR__ . '/../config.php'); + +require_login(); + +$reportid = required_param('id', PARAM_INT); +$download = required_param('download', PARAM_ALPHA); +$parameters = optional_param('parameters', null, PARAM_RAW); + +$reportpersistent = new \core_reportbuilder\local\models\report($reportid); +$context = $reportpersistent->get_context(); + +$PAGE->set_context($context); +$PAGE->set_url(new moodle_url('/reportbuilder/download.php')); + +$systemreport = system_report_factory::create($reportpersistent->get('source'), $context); +if (!$systemreport->can_be_downloaded()) { + throw new \core_reportbuilder\report_access_exception(); +} + +// Combine original report parameters with 'download' parameter. +$reportparameters = ['download' => $download]; +if ($parameters) { + $reportparameters = array_merge($reportparameters, (array) json_decode($parameters)); +} + +$outputreport = new \core_reportbuilder\output\system_report($reportpersistent, $systemreport, $reportparameters); +echo $PAGE->get_renderer('core_reportbuilder')->render($outputreport); diff --git a/reportbuilder/templates/local/filters/area.mustache b/reportbuilder/templates/local/filters/area.mustache new file mode 100644 index 00000000000..29b3a559968 --- /dev/null +++ b/reportbuilder/templates/local/filters/area.mustache @@ -0,0 +1,53 @@ +{{! + This file is part of Moodle - http://moodle.org/ + + Moodle is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Moodle is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Moodle. If not, see . +}} +{{! + @template core_reportbuilder/filters/area + + Template for system report filters area + + Example context (json): + { + "id": 3, + "filtersform": "form" + } +}} + + + +{{#js}} + require(['core_reportbuilder/filters'], function(filters) { + filters.init('{{id}}'); + }); +{{/js}} diff --git a/reportbuilder/templates/local/filters/footer.mustache b/reportbuilder/templates/local/filters/footer.mustache new file mode 100644 index 00000000000..cf3b3f5e870 --- /dev/null +++ b/reportbuilder/templates/local/filters/footer.mustache @@ -0,0 +1,26 @@ +{{! + This file is part of Moodle - http://moodle.org/ + + Moodle is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Moodle is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Moodle. If not, see . +}} +{{! + @template core_reportbuilder/local/filters/footer + + Template for the footer of a filter instance within the filter form + + Example context (json): + { + } +}} + \ No newline at end of file diff --git a/reportbuilder/templates/local/filters/header.mustache b/reportbuilder/templates/local/filters/header.mustache new file mode 100644 index 00000000000..031e009e09f --- /dev/null +++ b/reportbuilder/templates/local/filters/header.mustache @@ -0,0 +1,32 @@ +{{! + This file is part of Moodle - http://moodle.org/ + + Moodle is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Moodle is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Moodle. If not, see . +}} +{{! + @template core_reportbuilder/local/filters/header + + Template for the header of a filter instance within the filter form + + Example context (json): + { + "name": "Date modified" + } +}} +
+
+
+ {{name}} +
+
\ No newline at end of file diff --git a/reportbuilder/templates/system_report.mustache b/reportbuilder/templates/system_report.mustache new file mode 100644 index 00000000000..5cc996fe2a1 --- /dev/null +++ b/reportbuilder/templates/system_report.mustache @@ -0,0 +1,49 @@ +{{! + This file is part of Moodle - http://moodle.org/ + + Moodle is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Moodle is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Moodle. If not, see . +}} +{{! + @template core_reportbuilder/system_report + + Template for a system report + + Example context (json): + { + "id": 1, + "source": "some\\class\\name", + "parameters": [], + "table": "table", + "filterspresent": true, + "filtersform": "form" + } +}} +
+
+ {{#filterspresent}} + {{>core_reportbuilder/local/filters/area}} + {{/filterspresent}} + + {{{table}}} +
+
+ +{{#js}} + require(['core_reportbuilder/report'], function(report) { + report.init('{{id}}'); + }); +{{/js}} diff --git a/reportbuilder/tests/behat/behat_reportbuilder.php b/reportbuilder/tests/behat/behat_reportbuilder.php new file mode 100644 index 00000000000..0ea4a6d2b53 --- /dev/null +++ b/reportbuilder/tests/behat/behat_reportbuilder.php @@ -0,0 +1,43 @@ +. + +declare(strict_types=1); + +// NOTE: no MOODLE_INTERNAL test here, this file may be required by behat before including /config.php. +require_once(__DIR__ . '/../../../lib/behat/behat_base.php'); + +/** + * Behat step definitions for Reportbuilder + * + * @package core_reportbuilder + * @copyright 2021 Paul Holden + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class behat_reportbuilder extends behat_base { + + /** + * Return the list of partial named selectors + * + * @return behat_component_named_selector[] + */ + public static function get_partial_named_selectors(): array { + return [ + new behat_component_named_selector('Filter', [ + ".//*[@data-region='filters-form']//*[@data-filter-for=%locator%]", + ]), + ]; + } +} diff --git a/reportbuilder/tests/coverage.php b/reportbuilder/tests/coverage.php new file mode 100644 index 00000000000..1948492a919 --- /dev/null +++ b/reportbuilder/tests/coverage.php @@ -0,0 +1,43 @@ +. + +/** + * Coverage information for core_reportbuilder + * + * @package core_reportbuilder + * @copyright 2020 Paul Holden + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +declare(strict_types=1); + +defined('MOODLE_INTERNAL') || die(); + +return new class extends phpunit_coverage_info { + /** @var array The list of folders relative to the plugin root to include in coverage generation. */ + protected $includelistfolders = [ + 'classes', + ]; + + /** @var array The list of files relative to the plugin root to include in coverage generation. */ + protected $includelistfiles = []; + + /** @var array The list of folders relative to the plugin root to exclude in coverage generation. */ + protected $excludelistfolders = []; + + /** @var array The list of files relative to the plugin root to exclude in coverage generation. */ + protected $excludelistfiles = []; +}; diff --git a/reportbuilder/tests/external/filters/reset_test.php b/reportbuilder/tests/external/filters/reset_test.php new file mode 100644 index 00000000000..9f5665bdd35 --- /dev/null +++ b/reportbuilder/tests/external/filters/reset_test.php @@ -0,0 +1,61 @@ +. + +declare(strict_types=1); + +namespace core_reportbuilder\external\filters; + +use external_api; +use externallib_advanced_testcase; +use core_reportbuilder\local\helpers\user_filter_manager; + +defined('MOODLE_INTERNAL') || die(); + +global $CFG; +require_once("{$CFG->dirroot}/webservice/tests/helpers.php"); + +/** + * Unit tests external filters reset class + * + * @package core_reportbuilder + * @covers \core_reportbuilder\external\filters\reset + * @copyright 2021 Paul Holden + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class reset_testcase extends externallib_advanced_testcase { + + /** + * Text execute method + */ + public function test_execute(): void { + $this->resetAfterTest(); + $this->setAdminUser(); + + user_filter_manager::set(5, [ + 'entity:filter_name' => 'something', + ]); + + $this->assertCount(1, user_filter_manager::get(5)); + + $result = reset::execute(5); + $result = external_api::clean_returnvalue(reset::execute_returns(), $result); + + $this->assertTrue($result); + + // We should get an empty array back. + $this->assertEquals([], user_filter_manager::get(5)); + } +} diff --git a/reportbuilder/tests/external/system_report_exporter_test.php b/reportbuilder/tests/external/system_report_exporter_test.php new file mode 100644 index 00000000000..b0e730349bf --- /dev/null +++ b/reportbuilder/tests/external/system_report_exporter_test.php @@ -0,0 +1,94 @@ +. + +declare(strict_types=1); + +namespace core_reportbuilder\external; + +use advanced_testcase; +use context_system; +use moodle_url; +use core_reportbuilder\system_report_available; +use core_reportbuilder\system_report_factory; + +/** + * Unit tests for system report exporter + * + * @package core_reportbuilder + * @covers \core_reportbuilder\external\system_report_exporter + * @copyright 2021 Paul Holden + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class system_report_exporter_testcase extends advanced_testcase { + + /** + * Load test fixture + */ + public static function setUpBeforeClass(): void { + global $CFG; + + require_once("{$CFG->dirroot}/reportbuilder/tests/fixtures/system_report_available.php"); + } + + /** + * Data provider for {@see test_export} + * + * @return array[] + */ + public function export_provider(): array { + return [ + ['With filters' => true], + ['Without filters' => false], + ]; + } + + /** + * Text execute method + * + * @param bool $withfilters + * + * @dataProvider export_provider + */ + public function test_export(bool $withfilters): void { + global $PAGE; + + $this->resetAfterTest(); + + // Prevent debug warnings from flexible_table. + $PAGE->set_url(new moodle_url('/')); + + $systemreport = system_report_factory::create(system_report_available::class, context_system::instance(), '', '', 0, + ['withfilters' => $withfilters]); + + $exporter = new system_report_exporter($systemreport->get_report_persistent(), [ + 'source' => $systemreport, + 'parameters' => json_encode($systemreport->get_parameters()), + ]); + + $data = $exporter->export($PAGE->get_renderer('core_reportbuilder')); + $this->assertNotEmpty($data->table); + + if ($withfilters) { + $this->assertEquals('{"withfilters":true}', $data->parameters); + $this->assertTrue($data->filterspresent); + $this->assertNotEmpty($data->filtersform); + } else { + $this->assertEquals('{"withfilters":false}', $data->parameters); + $this->assertFalse($data->filterspresent); + $this->assertEmpty($data->filtersform); + } + } +} diff --git a/reportbuilder/tests/fixtures/course_entity_report.php b/reportbuilder/tests/fixtures/course_entity_report.php new file mode 100644 index 00000000000..239c0554b10 --- /dev/null +++ b/reportbuilder/tests/fixtures/course_entity_report.php @@ -0,0 +1,76 @@ +. + +declare(strict_types=1); + +namespace core_reportbuilder; + +use core_reportbuilder\local\entities\course; +use core_reportbuilder\local\helpers\database; + +/** + * Testable system report fixture for testing the course entity + * + * @package core_reportbuilder + * @copyright 2021 David Matamoros + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class course_entity_report extends system_report { + + /** + * Initialise the report + */ + protected function initialise(): void { + $entity = new course(); + $coursetablealias = $entity->get_table_alias('course'); + $param = database::generate_param_name(); + + $this->set_main_table('course', $coursetablealias); + $this->add_entity($entity); + // Add a base condition to hide the site course. + $this->add_base_condition_sql("$coursetablealias.id <> :$param", [$param => SITEID]); + + $columns = []; + foreach ($entity->get_columns() as $column) { + $columns[] = $column->get_unique_identifier(); + } + $this->add_columns_from_entities($columns); + + $filters = []; + foreach ($entity->get_filters() as $filter) { + $filters[] = $filter->get_unique_identifier(); + } + $this->add_filters_from_entities($filters); + } + + /** + * Ensure we can view the report + * + * @return bool + */ + protected function can_view(): bool { + return true; + } + + /** + * Explicitly set availability of report + * + * @return bool + */ + public static function is_available(): bool { + return true; + } +} diff --git a/reportbuilder/tests/fixtures/system_report_available.php b/reportbuilder/tests/fixtures/system_report_available.php new file mode 100644 index 00000000000..f0f4591c291 --- /dev/null +++ b/reportbuilder/tests/fixtures/system_report_available.php @@ -0,0 +1,95 @@ +. + +declare(strict_types=1); + +namespace core_reportbuilder; + +use core_reportbuilder\local\report\action; +use lang_string; +use core_reportbuilder\local\filters\text; +use core_reportbuilder\local\report\column; +use core_reportbuilder\local\report\filter; +use moodle_url; +use pix_icon; + +/** + * Testable system report fixture + * + * @package core_reportbuilder + * @copyright 2020 Paul Holden + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class system_report_available extends system_report { + + /** + * Initialise the report + */ + protected function initialise(): void { + $this->set_main_table('user', 'u'); + $this->annotate_entity('user', new lang_string('user')); + + $this->add_column((new column( + 'username', + new lang_string('username'), + 'user' + )) + ->add_joins($this->get_joins()) + ->add_field('u.firstname') + ); + + $withfilters = $this->get_parameter('withfilters', false, PARAM_BOOL); + if ($withfilters) { + $this->add_filter((new filter( + text::class, + 'username', + new lang_string('username'), + 'user', + 'u.username' + )) + ->add_joins($this->get_joins()) + ); + } + + $withactions = $this->get_parameter('withactions', false, PARAM_BOOL); + if ($withactions) { + $this->add_action(new action( + new moodle_url('/user/profile.php', ['id' => ':id']), + new pix_icon('e/search', get_string('view')), + [], + true, + )); + } + } + + /** + * Ensure we can view the report + * + * @return bool + */ + protected function can_view(): bool { + return true; + } + + /** + * Explicitly set availability of report + * + * @return bool + */ + public static function is_available(): bool { + return true; + } +} diff --git a/reportbuilder/tests/fixtures/system_report_unavailable.php b/reportbuilder/tests/fixtures/system_report_unavailable.php new file mode 100644 index 00000000000..435445c02c6 --- /dev/null +++ b/reportbuilder/tests/fixtures/system_report_unavailable.php @@ -0,0 +1,42 @@ +. + +declare(strict_types=1); + +namespace core_reportbuilder; + +defined('MOODLE_INTERNAL') || die(); + +require_once(__DIR__ . '/system_report_available.php'); + +/** + * Testable unavailable system report fixture + * + * @package core_reportbuilder + * @copyright 2020 Paul Holden + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class system_report_unavailable extends system_report_available { + + /** + * Explicitly set availability of report to false + * + * @return bool + */ + public static function is_available(): bool { + return false; + } +} diff --git a/reportbuilder/tests/fixtures/testable_system_report_table.php b/reportbuilder/tests/fixtures/testable_system_report_table.php new file mode 100644 index 00000000000..f6f76ee5e4d --- /dev/null +++ b/reportbuilder/tests/fixtures/testable_system_report_table.php @@ -0,0 +1,75 @@ +. + +declare(strict_types=1); + +namespace core_reportbuilder; + +use stdClass; +use core_reportbuilder\table\system_report_table; + +/** + * Testable system report table for getting report data + * + * @package core_reportbuilder + * @copyright 2021 Paul Holden + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class testable_system_report_table extends system_report_table { + + /** + * Format each row of returned data, replacing aliased column names with the original + * + * @param array|stdClass $row + * @return array + */ + public function format_row($row): array { + $record = parent::format_row($row); + $result = []; + + $columns = $this->systemreport->get_columns(); + foreach ($columns as $column) { + $result[$column->get_name()] = $record[$column->get_column_alias()]; + } + + return $result; + } + + /** + * Return all table rows + * + * @return array + */ + public function get_table_rows(): array { + global $PAGE; + + $PAGE->set_url('/'); + + $result = []; + + $this->guess_base_url(); + $this->setup(); + + $this->query_db(0, false); + foreach ($this->rawdata as $record) { + $result[] = $this->format_row($record); + } + + $this->close_recordset(); + + return $result; + } +} diff --git a/reportbuilder/tests/fixtures/user_entity_report.php b/reportbuilder/tests/fixtures/user_entity_report.php new file mode 100644 index 00000000000..721de588a2f --- /dev/null +++ b/reportbuilder/tests/fixtures/user_entity_report.php @@ -0,0 +1,71 @@ +. + +declare(strict_types=1); + +namespace core_reportbuilder; + +use core_reportbuilder\local\entities\user; + +/** + * Testable system report fixture for testing the user entity + * + * @package core_reportbuilder + * @copyright 2021 Paul Holden + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class user_entity_report extends system_report { + + /** + * Initialise the report + */ + protected function initialise(): void { + $entity = new user(); + + $this->set_main_table('user', $entity->get_table_alias('user')); + $this->add_entity($entity); + + $columns = []; + foreach ($entity->get_columns() as $column) { + $columns[] = $column->get_unique_identifier(); + } + $this->add_columns_from_entities($columns); + + $filters = []; + foreach ($entity->get_filters() as $filter) { + $filters[] = $filter->get_unique_identifier(); + } + $this->add_filters_from_entities($filters); + } + + /** + * Ensure we can view the report + * + * @return bool + */ + protected function can_view(): bool { + return true; + } + + /** + * Explicitly set availability of report + * + * @return bool + */ + public static function is_available(): bool { + return true; + } +} diff --git a/reportbuilder/tests/local/entities/course_test.php b/reportbuilder/tests/local/entities/course_test.php new file mode 100644 index 00000000000..884e4d8d05b --- /dev/null +++ b/reportbuilder/tests/local/entities/course_test.php @@ -0,0 +1,475 @@ +. + +declare(strict_types=1); + +namespace core_reportbuilder\local\entities; + +use advanced_testcase; +use coding_exception; +use context_system; +use lang_string; +use ReflectionClass; +use core_reportbuilder\course_entity_report; +use core_reportbuilder\manager; +use core_reportbuilder\testable_system_report_table; +use core_reportbuilder\local\filters\boolean_select; +use core_reportbuilder\local\filters\date; +use core_reportbuilder\local\filters\text; +use core_reportbuilder\local\filters\select; +use core_reportbuilder\local\helpers\user_filter_manager; + +/** + * Unit tests for course entity + * + * @package core_reportbuilder + * @covers \core_reportbuilder\local\entities\base + * @covers \core_reportbuilder\local\entities\course + * @covers \core_reportbuilder\local\helpers\custom_fields + * @covers \core_reportbuilder\local\report\base + * @covers \core_reportbuilder\system_report + * @copyright 2021 David Matamoros + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class course_testcase extends advanced_testcase { + + /** + * Load required classes + */ + public static function setUpBeforeClass(): void { + global $CFG; + + require_once("{$CFG->dirroot}/course/lib.php"); + require_once("{$CFG->dirroot}/reportbuilder/tests/fixtures/testable_system_report_table.php"); + require_once("{$CFG->dirroot}/reportbuilder/tests/fixtures/course_entity_report.php"); + } + + /** + * Generate courses for the tests + */ + public function generate_courses(): array { + $coursecategory1 = $this->getDataGenerator()->create_category(); + $course1 = $this->getDataGenerator()->create_course([ + 'fullname' => 'Course 1', + 'shortname' => 'C1', + 'idnumber' => 'IDNumber1', + 'visible' => 1, + 'startdate' => 289308600, + 'enddate' => 3445023600, + 'category' => $coursecategory1->id, + 'groupmode' => NOGROUPS, + 'enablecompletion' => 1, + 'downloadcontent' => DOWNLOAD_COURSE_CONTENT_DISABLED, + 'format' => 'topics', + 'calendartype' => 'Gregorian', + 'theme' => 'afterburner', + 'lang' => 'en', + ]); + + $coursecategory2 = $this->getDataGenerator()->create_category(); + $course2 = $this->getDataGenerator()->create_course([ + 'fullname' => 'Course 2', + 'shortname' => 'C2', + 'idnumber' => 'IDNumber2', + 'visible' => 0, + 'startdate' => 1614726000, + 'enddate' => 1961881200, + 'category' => $coursecategory2->id, + 'groupmode' => SEPARATEGROUPS, + 'enablecompletion' => 0, + 'downloadcontent' => DOWNLOAD_COURSE_CONTENT_ENABLED, + 'format' => 'topics', + 'calendartype' => 'Gregorian', + 'theme' => 'afterburner', + 'lang' => 'es', + ]); + + return [$coursecategory1, $course1, $coursecategory2, $course2]; + } + + /** + * Test callbacks are correctly applied for those columns using them + */ + public function test_get_columns_with_callbacks(): void { + $this->resetAfterTest(); + + [$coursecategory1, $course1] = $this->generate_courses(); + $testdate = time(); + + // Add some customfields to the course. + $cfgenerator = self::getDataGenerator()->get_plugin_generator('core_customfield'); + $params = [ + 'component' => 'core_course', + 'area' => 'course', + 'itemid' => 0, + 'contextid' => context_system::instance()->id + ]; + $category = $cfgenerator->create_category($params); + $field1 = $cfgenerator->create_field( + ['categoryid' => $category->get('id'), 'type' => 'text', 'name' => 'Customfield text 1', 'shortname' => 'cf1']); + $cfgenerator->add_instance_data($field1, (int)$course1->id, 'Do. Or do not. There is no try'); + $field2 = $cfgenerator->create_field( + ['categoryid' => $category->get('id'), 'type' => 'text', 'name' => 'Customfield text 2', 'shortname' => 'cf2']); + $cfgenerator->add_instance_data($field2, (int)$course1->id, 'Chewie, we are home'); + $field3 = $cfgenerator->create_field( + ['categoryid' => $category->get('id'), 'type' => 'checkbox', 'name' => 'Customfield checkbox', 'shortname' => 'cf3']); + $cfgenerator->add_instance_data($field3, (int)$course1->id, 1); + $field4 = $cfgenerator->create_field( + ['categoryid' => $category->get('id'), 'type' => 'date', 'name' => 'Customfield date', 'shortname' => 'cf4']); + $cfgenerator->add_instance_data($field4, (int)$course1->id, $testdate); + $field5 = $cfgenerator->create_field( + ['categoryid' => $category->get('id'), 'type' => 'select', 'name' => 'Customfield menu', 'shortname' => 'cf5', + 'configdata' => ['defaultvalue' => 'Option A', 'options' => "Option A\nOption B\nOption C"]]); + // Select option C for course1 (options are counted starting from one). + $cfgenerator->add_instance_data($field5, (int)$course1->id, 3); + + $tablerows = $this->get_report_table_rows(); + $courserows = array_filter($tablerows, static function(array $row) use ($course1): bool { + return $row['shortname'] === $course1->shortname; + }); + $courserow = reset($courserows); + + $this->assertEquals($coursecategory1->name, $courserow['category']); + $this->assertEquals('Course 1', $courserow['fullname']); + $this->assertEquals('C1', $courserow['shortname']); + $this->assertEquals('IDNumber1', $courserow['idnumber']); + $this->assertEquals('Yes', $courserow['visible']); + $this->assertEquals(userdate(289308600), $courserow['startdate']); + $this->assertEquals(userdate(3445023600), $courserow['enddate']); + $this->assertEquals('No groups', $courserow['groupmode']); + $this->assertEquals('Yes', $courserow['enablecompletion']); + $this->assertEquals('No', $courserow['downloadcontent']); + $this->assertEquals('Topics format', $courserow['format']); + $this->assertEquals('Gregorian', $courserow['calendartype']); + $this->assertEquals('afterburner', $courserow['theme']); + $this->assertEquals(get_string_manager()->get_list_of_translations()['en'], $courserow['lang']); + $expected = 'Course 1'; + $this->assertEquals($expected, $courserow['coursefullnamewithlink']); + $expected = 'C1'; + $this->assertEquals($expected, $courserow['courseshortnamewithlink']); + $expected = 'IDNumber1'; + $this->assertEquals($expected, $courserow['courseidnumberewithlink']); + $this->assertEquals('Do. Or do not. There is no try', $courserow['customfield_cf1']); + $this->assertEquals('Chewie, we are home', $courserow['customfield_cf2']); + $this->assertEquals('Yes', $courserow['customfield_cf3']); + $this->assertEquals(userdate($testdate), $courserow['customfield_cf4']); + $this->assertEquals('Option C', $courserow['customfield_cf5']); + } + + /** + * Test filtering report by course fields + */ + public function test_filters(): void { + $this->resetAfterTest(); + + [$coursecategory1] = $this->generate_courses(); + + // Filter by fullname field. + $filtervalues = [ + 'course:fullname_operator' => text::IS_EQUAL_TO, + 'course:fullname_value' => 'Course 1', + ]; + $tablerows = $this->get_report_table_rows($filtervalues); + $this->assertEquals([ + 'Course 1', + ], array_column($tablerows, 'fullname')); + + // Filter by shortname field. + $filtervalues = [ + 'course:shortname_operator' => text::IS_EQUAL_TO, + 'course:shortname_value' => 'C1', + ]; + $tablerows = $this->get_report_table_rows($filtervalues); + $this->assertEquals([ + 'Course 1', + ], array_column($tablerows, 'fullname')); + + // Filter by idnumber field. + $filtervalues = [ + 'course:idnumber_operator' => text::IS_EQUAL_TO, + 'course:idnumber_value' => 'IDNumber2', + ]; + $tablerows = $this->get_report_table_rows($filtervalues); + $this->assertEquals([ + 'Course 2', + ], array_column($tablerows, 'fullname')); + + // Filter by visible field. + $filtervalues = [ + 'course:visible_operator' => boolean_select::NOT_CHECKED, + ]; + $tablerows = $this->get_report_table_rows($filtervalues); + $this->assertEquals([ + 'Course 2', + ], array_column($tablerows, 'fullname')); + + // Filter by startdate field. + $filtervalues = [ + 'course:startdate_operator' => date::DATE_RANGE, + 'course:startdate_from' => 289135800, + 'course:startdate_to' => 289740600, + ]; + $tablerows = $this->get_report_table_rows($filtervalues); + $this->assertEquals([ + 'Course 1', + ], array_column($tablerows, 'fullname')); + + // Filter by category field. + $filtervalues = [ + 'course:category_operator' => select::EQUAL_TO, + 'course:category_value' => $coursecategory1->id, + ]; + $tablerows = $this->get_report_table_rows($filtervalues); + $this->assertEquals([ + 'Course 1', + ], array_column($tablerows, 'fullname')); + + // Filter by group mode field. + $filtervalues = [ + 'course:groupmode_operator' => select::EQUAL_TO, + 'course:groupmode_value' => SEPARATEGROUPS, + ]; + $tablerows = $this->get_report_table_rows($filtervalues); + $this->assertEquals([ + 'Course 2', + ], array_column($tablerows, 'fullname')); + + // Filter by enable completion field. + $filtervalues = [ + 'course:enablecompletion_operator' => boolean_select::CHECKED, + ]; + $tablerows = $this->get_report_table_rows($filtervalues); + $this->assertEquals([ + 'Course 1', + ], array_column($tablerows, 'fullname')); + } + + /** + * Test filtering report by course customfield + */ + public function test_customfield_text_filter(): void { + $this->resetAfterTest(); + + $course1 = $this->getDataGenerator()->create_course([ + 'fullname' => 'Philosophy and Superheroes', + 'shortname' => 'PS1', + ]); + $course2 = $this->getDataGenerator()->create_course([ + 'fullname' => 'The game of Mathematics', + 'shortname' => 'GM1', + ]); + + // Add some customfields to the course. + $cfgenerator = self::getDataGenerator()->get_plugin_generator('core_customfield'); + $params = [ + 'component' => 'core_course', + 'area' => 'course', + 'itemid' => 0, + 'contextid' => context_system::instance()->id + ]; + $category = $cfgenerator->create_category($params); + $field = $cfgenerator->create_field( + ['categoryid' => $category->get('id'), 'type' => 'text', 'name' => 'Customfield text 1', 'shortname' => 'cf']); + $cfgenerator->add_instance_data($field, (int)$course1->id, 'Leia Organa'); + $cfgenerator->add_instance_data($field, (int)$course2->id, 'Han Solo'); + $field5 = $cfgenerator->create_field( + ['categoryid' => $category->get('id'), 'type' => 'select', 'name' => 'Customfield menu', 'shortname' => 'cf5', + 'configdata' => ['defaultvalue' => 'Option A', 'options' => "Option A\nOption B\nOption C"]]); + $cfgenerator->add_instance_data($field5, (int)$course1->id, 3); + $cfgenerator->add_instance_data($field5, (int)$course2->id, 2); + + $filtervalues = [ + 'course:customfield_cf_operator' => text::IS_EQUAL_TO, + 'course:customfield_cf_value' => 'Leia Organa', + ]; + $tablerows = $this->get_report_table_rows($filtervalues); + $this->assertEquals([ + 'Philosophy and Superheroes', + ], array_column($tablerows, 'fullname')); + + $filtervalues = [ + 'course:customfield_cf_operator' => text::IS_EQUAL_TO, + 'course:customfield_cf_value' => 'Han Solo', + ]; + $tablerows = $this->get_report_table_rows($filtervalues); + $this->assertEquals([ + 'The game of Mathematics', + ], array_column($tablerows, 'fullname')); + + // Filter by menu customfield. + $filtervalues = [ + 'course:customfield_cf5_operator' => select::EQUAL_TO, + 'course:customfield_cf5_value' => 3, // Option C. + ]; + $tablerows = $this->get_report_table_rows($filtervalues); + $this->assertEquals([ + 'Philosophy and Superheroes', + ], array_column($tablerows, 'fullname')); + + // Filter by course customfield that doesn't exist. + $filtervalues = [ + 'course:customfield_cf_operator' => text::IS_EQUAL_TO, + 'course:customfield_cf_value' => 'Luke Skywalker', + ]; + $tablerows = $this->get_report_table_rows($filtervalues); + $this->assertEmpty($tablerows); + } + + /** + * Helper method to create the report, and return it's rows + * + * @param array $filtervalues + * @return array + */ + private function get_report_table_rows(array $filtervalues = []): array { + $report = manager::create_report_persistent((object) [ + 'type' => course_entity_report::TYPE_SYSTEM_REPORT, + 'source' => course_entity_report::class, + ]); + + user_filter_manager::set($report->get('id'), $filtervalues); + + return testable_system_report_table::create($report->get('id'), [])->get_table_rows(); + } + + /** + * Test entity table alias + */ + public function test_table_alias(): void { + $courseentity = new course(); + + $this->assertEquals('c', $courseentity->get_table_alias('course')); + + $courseentity->set_table_alias('course', 'newalias'); + $this->assertEquals('newalias', $courseentity->get_table_alias('course')); + } + + /** + * Test for invalid get table alias + */ + public function test_get_table_alias_invalid(): void { + $courseentity = new course(); + + $this->expectException(coding_exception::class); + $this->expectExceptionMessage('Coding error detected, it must be fixed by a programmer: ' . + 'Invalid table name (nonexistingalias)'); + $courseentity->get_table_alias('nonexistingalias'); + } + + /** + * Test invalid entity set table alias + */ + public function test_set_table_alias_invalid(): void { + $courseentity = new course(); + + $this->expectException(coding_exception::class); + $this->expectExceptionMessage('Coding error detected, it must be fixed by a programmer: Invalid table name (nonexistent)'); + $courseentity->set_table_alias('nonexistent', 'newalias'); + } + + /** + * Test entity name + */ + public function test_name(): void { + $courseentity = new course(); + + $this->assertEquals('course', $courseentity->get_entity_name()); + + $courseentity->set_entity_name('newentityname'); + $this->assertEquals('newentityname', $courseentity->get_entity_name()); + } + + /** + * Test invalid entity name + */ + public function test_name_invalid(): void { + $courseentity = new course(); + + $this->expectException(coding_exception::class); + $this->expectExceptionMessage('Entity name must be comprised of alphanumeric character, underscore or dash'); + $courseentity->set_entity_name(''); + } + + /** + * Test entity title + */ + public function test_title(): void { + $courseentity = new course(); + + $this->assertEquals(new lang_string('entitycourse', 'core_reportbuilder'), $courseentity->get_entity_title()); + + $newtitle = new lang_string('fullname'); + $courseentity->set_entity_title($newtitle); + $this->assertEquals($newtitle, $courseentity->get_entity_title()); + } + + /** + * Test adding single join + */ + public function test_add_join(): void { + $courseentity = (new course()) + ->set_table_alias('course', 'c1'); + + $tablejoin = "JOIN {course} c2 ON c2.id = c1.id"; + $courseentity->add_join($tablejoin); + + $method = (new ReflectionClass(course::class))->getMethod('get_joins'); + $method->setAccessible(true); + $this->assertEquals([$tablejoin], $method->invoke($courseentity)); + } + + /** + * Test adding multiple join + */ + public function test_add_joins(): void { + $courseentity = (new course()) + ->set_table_alias('course', 'c1'); + + $tablejoins = [ + "JOIN {course} c2 ON c2.id = c1.id", + "JOIN {course} c3 ON c3.id = c1.id", + ]; + $courseentity->add_joins($tablejoins); + + $method = (new ReflectionClass(course::class))->getMethod('get_joins'); + $method->setAccessible(true); + $this->assertEquals($tablejoins, $method->invoke($courseentity)); + } + + /** + * Test for invalid get column + */ + public function test_get_column_invalid(): void { + $courseentity = new course(); + + $this->expectException(coding_exception::class); + $this->expectExceptionMessage('Coding error detected, it must be fixed by a programmer: ' . + 'Invalid column name (nonexistingcolumn)'); + $courseentity->get_column('nonexistingcolumn'); + } + + /** + * Test for invalid get filter + */ + public function test_get_filter_invalid(): void { + $courseentity = new course(); + + $this->expectException(coding_exception::class); + $this->expectExceptionMessage('Coding error detected, it must be fixed by a programmer: ' . + 'Invalid filter name (nonexistingfilter)'); + $courseentity->get_filter('nonexistingfilter'); + } +} diff --git a/reportbuilder/tests/local/entities/user_test.php b/reportbuilder/tests/local/entities/user_test.php new file mode 100644 index 00000000000..4ba2762da67 --- /dev/null +++ b/reportbuilder/tests/local/entities/user_test.php @@ -0,0 +1,290 @@ +. + +declare(strict_types=1); + +namespace core_reportbuilder\local\entities; + +use advanced_testcase; +use core_reportbuilder\local\filters\boolean_select; +use core_reportbuilder\local\filters\date; +use moodle_url; +use core_reportbuilder\manager; +use core_reportbuilder\testable_system_report_table; +use core_reportbuilder\user_entity_report; +use core_reportbuilder\local\filters\text; +use core_reportbuilder\local\helpers\user_filter_manager; + +/** + * Unit tests for user entity + * + * @package core_reportbuilder + * @covers \core_reportbuilder\local\entities\base + * @covers \core_reportbuilder\local\entities\user + * @covers \core_reportbuilder\local\helpers\user_profile_fields + * @covers \core_reportbuilder\local\report\base + * @covers \core_reportbuilder\system_report + * @copyright 2021 Paul Holden + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class user_testcase extends advanced_testcase { + + /** + * Load required classes + */ + public static function setUpBeforeClass(): void { + global $CFG; + + require_once("{$CFG->dirroot}/reportbuilder/tests/fixtures/testable_system_report_table.php"); + require_once("{$CFG->dirroot}/reportbuilder/tests/fixtures/user_entity_report.php"); + require_once("{$CFG->dirroot}/user/profile/lib.php"); + } + + /** + * Test callbacks are correctly applied for those columns using them + */ + public function test_columns_with_callbacks(): void { + $this->resetAfterTest(); + + // Add a couple of user profile fields to show on the report. + $this->getDataGenerator()->create_custom_profile_field(['datatype' => 'text', + 'shortname' => 'favcolor', 'name' => 'Favorite color']); + $this->getDataGenerator()->create_custom_profile_field(['datatype' => 'text', + 'shortname' => 'favsuperpower', 'name' => 'Favorite super power']); + + $user = $this->getDataGenerator()->create_user([ + 'suspended' => 1, + 'confirmed' => 0, + 'country' => 'ES', + 'profile_field_favcolor' => 'Blue', + 'profile_field_favsuperpower' => 'Time travel', + ]); + + $tablerows = $this->get_report_table_rows(); + $userrows = array_filter($tablerows, static function(array $row) use ($user): bool { + return $row['username'] === $user->username; + }); + $userrow = reset($userrows); + + $this->assertEquals('Yes', $userrow['suspended']); + $this->assertEquals('No', $userrow['confirmed']); + $this->assertEquals('Spain', $userrow['country']); + $this->assertEquals('Blue', $userrow['profilefield_favcolor']); + $this->assertEquals('Time travel', $userrow['profilefield_favsuperpower']); + } + + /** + * Test the formatted user fullname columns + */ + public function test_fullname_columns(): void { + global $OUTPUT; + + $this->resetAfterTest(); + + $user = $this->getDataGenerator()->create_user([]); + + $tablerows = $this->get_report_table_rows(); + $userrows = array_filter($tablerows, static function(array $row) use ($user): bool { + return $row['username'] === $user->username; + }); + $userrow = reset($userrows); + + $userfullname = fullname($user); + $userprofile = (new moodle_url('/user/profile.php', ['id' => $user->id]))->out(); + $userpicture = $OUTPUT->user_picture($user, ['link' => false, 'alttext' => false]); + + $this->assertEquals($userfullname, $userrow['fullname']); + $this->assertEquals('' . $userfullname . '', $userrow['fullnamewithlink']); + $this->assertEquals($userpicture . $userfullname, $userrow['fullnamewithpicture']); + $this->assertEquals('' . $userpicture . $userfullname . '', + $userrow['fullnamewithpicturelink']); + } + + /** + * Test picture column callback + */ + public function test_picture_column(): void { + global $OUTPUT; + + $this->resetAfterTest(); + + $user = $this->getDataGenerator()->create_user([]); + + $tablerows = $this->get_report_table_rows(); + $userrows = array_filter($tablerows, static function(array $row) use ($user): bool { + return $row['username'] === $user->username; + }); + $userrow = reset($userrows); + + $userpicture = $OUTPUT->user_picture($user, ['link' => false, 'alttext' => false]); + $this->assertEquals($userpicture, $userrow['picture']); + } + + /** + * Test filtering report by user fields + */ + public function test_filters(): void { + $this->resetAfterTest(); + + $this->getDataGenerator()->create_user(['firstname' => 'Daffy', 'lastname' => 'Duck', 'email' => 'daffy@test.com', + 'city' => 'LA', 'lastaccess' => time() - YEARSECS, 'suspended' => 1]); + $this->getDataGenerator()->create_user(['firstname' => 'Donald', 'lastname' => 'Duck', 'email' => 'donald@test.com', + 'city' => 'Chicago', 'lastaccess' => time(), 'suspended' => 0]); + + // Filter by fullname field. + $filtervalues = [ + 'user:fullname_operator' => text::IS_EQUAL_TO, + 'user:fullname_value' => 'Daffy Duck', + ]; + $tablerows = $this->get_report_table_rows($filtervalues); + $this->assertEquals([ + 'Daffy Duck', + ], array_column($tablerows, 'fullname')); + + // Filter by firstname field. + $filtervalues = [ + 'user:firstname_operator' => text::CONTAINS, + 'user:firstname_value' => 'Donald', + ]; + $tablerows = $this->get_report_table_rows($filtervalues); + $this->assertEquals([ + 'Donald Duck', + ], array_column($tablerows, 'fullname')); + + // Filter by lastname field. + $filtervalues = [ + 'user:lastname_operator' => text::CONTAINS, + 'user:lastname_value' => 'Duck', + ]; + $tablerows = $this->get_report_table_rows($filtervalues); + $this->assertEqualsCanonicalizing([ + 'Donald Duck', + 'Daffy Duck', + ], array_column($tablerows, 'fullname')); + + // Filter by email field. + $filtervalues = [ + 'user:email_operator' => text::IS_EQUAL_TO, + 'user:email_value' => 'donald@test.com', + ]; + $tablerows = $this->get_report_table_rows($filtervalues); + $this->assertEquals([ + 'Donald Duck', + ], array_column($tablerows, 'fullname')); + + // Filter by city field. + $filtervalues = [ + 'user:city_operator' => text::IS_EQUAL_TO, + 'user:city_value' => 'Chicago', + ]; + $tablerows = $this->get_report_table_rows($filtervalues); + $this->assertEquals([ + 'Donald Duck', + ], array_column($tablerows, 'fullname')); + + // Filter by city field. + $filtervalues = [ + 'user:city_operator' => text::IS_EQUAL_TO, + 'user:city_value' => 'Chicago', + ]; + $tablerows = $this->get_report_table_rows($filtervalues); + $this->assertEquals([ + 'Donald Duck', + ], array_column($tablerows, 'fullname')); + + // Filter by lastaccess field. + $filtervalues = [ + 'user:lastaccess_operator' => date::DATE_RANGE, + 'user:lastaccess_from' => time() - YEARSECS - 100, + 'user:lastaccess_to' => time() - YEARSECS + 100, + ]; + $tablerows = $this->get_report_table_rows($filtervalues); + $this->assertEquals([ + 'Daffy Duck', + ], array_column($tablerows, 'fullname')); + + // Filter by suspened field. + $filtervalues = [ + 'user:suspended_operator' => boolean_select::CHECKED, + ]; + $tablerows = $this->get_report_table_rows($filtervalues); + $this->assertEquals([ + 'Daffy Duck', + ], array_column($tablerows, 'fullname')); + } + + /** + * Test filtering report by a user profile field + */ + public function test_userprofilefield_filter(): void { + $this->resetAfterTest(); + + // Add a user profile field to show on the report. + $this->getDataGenerator()->create_custom_profile_field(['datatype' => 'text', + 'shortname' => 'favcolor', 'name' => 'Favorite color']); + + $this->getDataGenerator()->create_user(['firstname' => 'Daffy', 'lastname' => 'Duck', 'profile_field_favcolor' => 'Blue']); + $this->getDataGenerator()->create_user(['firstname' => 'Donald', 'lastname' => 'Duck', + 'profile_field_favcolor' => 'Green']); + + $filtervalues = [ + 'user:profilefield_favcolor_operator' => text::IS_EQUAL_TO, + 'user:profilefield_favcolor_value' => 'Green', + ]; + + $tablerows = $this->get_report_table_rows($filtervalues); + $this->assertEquals([ + 'Donald Duck', + ], array_column($tablerows, 'fullname')); + } + + /** + * Tests the helper method for selecting all of a users' name fields + */ + public function test_get_name_fields_select(): void { + global $DB; + + $fields = user::get_name_fields_select('u'); + $user = $DB->get_record_sql("SELECT {$fields} FROM {user} u WHERE username = :username", ['username' => 'admin']); + + // Ensure we received back all name fields. + $this->assertEqualsCanonicalizing([ + 'firstname', + 'lastname', + 'firstnamephonetic', + 'lastnamephonetic', + 'middlename', + 'alternatename', + ], array_keys((array) $user)); + } + + /** + * Helper method to create the report, and return it's rows + * + * @param array $filtervalues + * @return array + */ + private function get_report_table_rows(array $filtervalues = []): array { + $report = manager::create_report_persistent((object) [ + 'type' => user_entity_report::TYPE_SYSTEM_REPORT, + 'source' => user_entity_report::class, + ]); + + user_filter_manager::set($report->get('id'), $filtervalues); + + return testable_system_report_table::create($report->get('id'), [])->get_table_rows(); + } +} diff --git a/reportbuilder/tests/local/filters/boolean_select_test.php b/reportbuilder/tests/local/filters/boolean_select_test.php new file mode 100644 index 00000000000..55f63deb883 --- /dev/null +++ b/reportbuilder/tests/local/filters/boolean_select_test.php @@ -0,0 +1,86 @@ +. + +declare(strict_types=1); + +namespace core_reportbuilder\local\filters; + +use advanced_testcase; +use lang_string; +use core_reportbuilder\local\report\filter; + +/** + * Unit tests for boolean report filter + * + * @package core_reportbuilder + * @covers \core_reportbuilder\local\filters\base + * @covers \core_reportbuilder\local\filters\boolean_select + * @copyright 2021 Paul Holden + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class boolean_select_testcase extends advanced_testcase { + + /** + * Data provider for {@see test_get_sql_filter_simple} + * + * @return array + */ + public function get_sql_filter_simple_provider(): array { + return [ + [boolean_select::ANY_VALUE, true], + [boolean_select::CHECKED, true], + [boolean_select::NOT_CHECKED, false], + ]; + } + + /** + * Test getting filter SQL + * + * @param int $operator + * @param bool $expectuser + * + * @dataProvider get_sql_filter_simple_provider + */ + public function test_get_sql_filter_simple(int $operator, bool $expectuser): void { + global $DB; + + $this->resetAfterTest(); + + $user = $this->getDataGenerator()->create_user([ + 'suspended' => 1, + ]); + + $filter = new filter( + boolean_select::class, + 'test', + new lang_string('yes'), + 'testentity', + 'suspended' + ); + + // Create instance of our filter, passing given operator. + [$select, $params] = boolean_select::create($filter)->get_sql_filter([ + $filter->get_unique_identifier() . '_operator' => $operator, + ]); + + $usernames = $DB->get_fieldset_select('user', 'username', $select, $params); + if ($expectuser) { + $this->assertContains($user->username, $usernames); + } else { + $this->assertNotContains($user->username, $usernames); + } + } +} diff --git a/reportbuilder/tests/local/filters/course_selector_test.php b/reportbuilder/tests/local/filters/course_selector_test.php new file mode 100644 index 00000000000..155fc98454a --- /dev/null +++ b/reportbuilder/tests/local/filters/course_selector_test.php @@ -0,0 +1,77 @@ +. + +declare(strict_types=1); + +namespace core_reportbuilder\local\filters; + +use advanced_testcase; +use core_reportbuilder\local\report\filter; + +/** + * Unit tests for course selector filter + * + * @package core_reportbuilder + * @covers \core_reportbuilder\local\filters\base + * @covers \core_reportbuilder\local\filters\course_selector + * @copyright 2021 David Matamoros + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class course_selector_testcase extends advanced_testcase { + + /** + * Test getting filter SQL + */ + public function test_get_sql_filter(): void { + global $DB; + + $this->resetAfterTest(); + + $course1 = $this->getDataGenerator()->create_course([ + 'fullname' => "Time travel", + ]); + + $course2 = $this->getDataGenerator()->create_course([ + 'fullname' => "Quantum computing", + ]); + + $course3 = $this->getDataGenerator()->create_course([ + 'fullname' => "Space travel", + ]); + + $filter = new filter( + course_selector::class, + 'test', + new \lang_string('course'), + 'testentity', + 'id' + ); + + // Create instance of our filter, passing given courses ids. + [$select, $params] = text::create($filter)->get_sql_filter([ + $filter->get_unique_identifier() . '_values' => [$course1->id, $course3->id], + ]); + $fullnames = $DB->get_fieldset_select('course', 'fullname', $select, $params); + $this->assertEqualsCanonicalizing(['Time travel', 'Space travel'], $fullnames); + + // Test without passing any course id. + [$select, $params] = text::create($filter)->get_sql_filter([ + $filter->get_unique_identifier() . '_values' => [], + ]); + $fullnames = $DB->get_fieldset_select('course', 'fullname', $select, $params); + $this->assertEqualsCanonicalizing(['Time travel', 'Quantum computing', 'Space travel', 'PHPUnit test site'], $fullnames); + } +} diff --git a/reportbuilder/tests/local/filters/date_test.php b/reportbuilder/tests/local/filters/date_test.php new file mode 100644 index 00000000000..e9073a4f72d --- /dev/null +++ b/reportbuilder/tests/local/filters/date_test.php @@ -0,0 +1,117 @@ +. + +declare(strict_types=1); + +namespace core_reportbuilder\local\filters; + +use advanced_testcase; +use lang_string; +use core_reportbuilder\local\report\filter; + +/** + * Unit tests for date report filter + * + * @package core_reportbuilder + * @covers \core_reportbuilder\local\filters\base + * @covers \core_reportbuilder\local\filters\date + * @copyright 2021 Paul Holden + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class date_testcase extends advanced_testcase { + + /** + * Data provider for {@see test_get_sql_filter_simple} + * + * @return array + */ + public function get_sql_filter_simple_provider(): array { + return [ + [date::DATE_ANY, true], + [date::DATE_NOT_EMPTY, true], + [date::DATE_EMPTY, false], + ]; + } + + /** + * Test getting filter SQL + * + * @param int $operator + * @param bool $expectuser + * + * @dataProvider get_sql_filter_simple_provider + */ + public function test_get_sql_filter_simple(int $operator, bool $expectuser): void { + global $DB; + + $this->resetAfterTest(); + + $user = $this->getDataGenerator()->create_user([ + 'timecreated' => 12345, + ]); + + $filter = new filter( + date::class, + 'test', + new lang_string('yes'), + 'testentity', + 'timecreated' + ); + + // Create instance of our filter, passing given operator. + [$select, $params] = date::create($filter)->get_sql_filter([ + $filter->get_unique_identifier() . '_operator' => $operator, + ]); + + $usernames = $DB->get_fieldset_select('user', 'username', $select, $params); + if ($expectuser) { + $this->assertContains($user->username, $usernames); + } else { + $this->assertNotContains($user->username, $usernames); + } + } + + /** + * Test getting filter SQL while specifying a date range + */ + public function test_get_sql_filter_date_range(): void { + global $DB; + + $this->resetAfterTest(); + + $userone = $this->getDataGenerator()->create_user(['timecreated' => 50]); + $usertwo = $this->getDataGenerator()->create_user(['timecreated' => 100]); + + $filter = new filter( + date::class, + 'test', + new lang_string('yes'), + 'testentity', + 'timecreated' + ); + + // Create instance of our date range filter. + [$select, $params] = date::create($filter)->get_sql_filter([ + $filter->get_unique_identifier() . '_operator' => date::DATE_RANGE, + $filter->get_unique_identifier() . '_from' => 80, + $filter->get_unique_identifier() . '_to' => 120, + ]); + + // The only matching user should be our first test user. + $usernames = $DB->get_fieldset_select('user', 'username', $select, $params); + $this->assertEquals([$usertwo->username], $usernames); + } +} diff --git a/reportbuilder/tests/local/filters/number_test.php b/reportbuilder/tests/local/filters/number_test.php new file mode 100644 index 00000000000..eb8b33dbedc --- /dev/null +++ b/reportbuilder/tests/local/filters/number_test.php @@ -0,0 +1,148 @@ +. + +declare(strict_types=1); + +namespace core_reportbuilder\local\filters; + +use advanced_testcase; +use lang_string; +use core_reportbuilder\local\report\filter; + +/** + * Unit tests for number report filter + * + * @package core_reportbuilder + * @covers \core_reportbuilder\local\filters\base + * @covers \core_reportbuilder\local\filters\number + * @copyright 2021 David Matamoros + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class number_testcase extends advanced_testcase { + + /** + * Data provider for {@see test_get_sql_filter_simple} + * + * @return array[] + */ + public function get_sql_filter_simple_provider(): array { + return [ + [number::ANY_VALUE, null, null, true], + [number::IS_NOT_EMPTY, null, null, true], + [number::IS_EMPTY, null, null, false], + [number::LESS_THAN, 1, null, false], + [number::LESS_THAN, 123, null, false], + [number::LESS_THAN, 124, null, true], + [number::GREATER_THAN, 1, null, true], + [number::GREATER_THAN, 123, null, false], + [number::GREATER_THAN, 124, null, false], + [number::EQUAL_TO, 123, null, true], + [number::EQUAL_TO, 124, null, false], + [number::EQUAL_OR_LESS_THAN, 124, null, true], + [number::EQUAL_OR_LESS_THAN, 123, null, true], + [number::EQUAL_OR_LESS_THAN, 122, null, false], + [number::EQUAL_OR_GREATER_THAN, 122, null, true], + [number::EQUAL_OR_GREATER_THAN, 123, null, true], + [number::EQUAL_OR_GREATER_THAN, 124, null, false], + [number::RANGE, 122, 124, true], + [number::RANGE, 124, 125, false], + [number::RANGE, 122, 123, true], + [number::RANGE, 123, 124, true], + ]; + } + + /** + * Test getting filter SQL + * + * @param int $operator + * @param int|null $value1 + * @param int|null $value2 + * @param bool $expectmatch + * + * @dataProvider get_sql_filter_simple_provider + */ + public function test_get_sql_filter_simple(int $operator, ?int $value1, ?int $value2, bool $expectmatch): void { + global $DB; + + $this->resetAfterTest(); + + $course = $this->getDataGenerator()->create_course([ + 'timecreated' => 123, + ]); + + $filter = new filter( + number::class, + 'test', + new lang_string('course'), + 'testentity', + 'timecreated' + ); + + // Create instance of our filter, passing given operator. + [$select, $params] = number::create($filter)->get_sql_filter([ + $filter->get_unique_identifier() . '_value1' => $value1, + $filter->get_unique_identifier() . '_value2' => $value2, + $filter->get_unique_identifier() . '_operator' => $operator, + ]); + + $fullnames = $DB->get_fieldset_select('course', 'fullname', $select, $params); + if ($expectmatch) { + $this->assertContains($course->fullname, $fullnames); + } else { + $this->assertNotContains($course->fullname, $fullnames); + } + } + + /** + * Data provider for {@see test_get_sql_filter_invalid} + * + * @return array[] + */ + public function get_sql_filter_invalid_provider(): array { + return [ + [number::LESS_THAN], + [number::GREATER_THAN], + [number::EQUAL_TO], + [number::EQUAL_OR_LESS_THAN], + [number::EQUAL_OR_GREATER_THAN], + [number::RANGE], + ]; + } + + /** + * Test getting filter SQL for operators that require values + * + * @param int $operator + * + * @dataProvider get_sql_filter_invalid_provider + */ + public function test_get_sql_filter_invalid(int $operator): void { + $filter = new filter( + number::class, + 'test', + new lang_string('course'), + 'testentity', + 'timecreated' + ); + + [$select, $params] = number::create($filter)->get_sql_filter([ + $filter->get_unique_identifier() . '_operator' => $operator, + ]); + + $this->assertEquals('', $select); + $this->assertEquals([], $params); + } +} diff --git a/reportbuilder/tests/local/filters/select_test.php b/reportbuilder/tests/local/filters/select_test.php new file mode 100644 index 00000000000..f0fe6567be6 --- /dev/null +++ b/reportbuilder/tests/local/filters/select_test.php @@ -0,0 +1,98 @@ +. + +declare(strict_types=1); + +namespace core_reportbuilder\local\filters; + +use advanced_testcase; +use lang_string; +use core_reportbuilder\local\report\filter; + +/** + * Unit tests for select report filter + * + * @package core_reportbuilder + * @covers \core_reportbuilder\local\filters\base + * @covers \core_reportbuilder\local\filters\select + * @copyright 2021 David Matamoros + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class select_testcase extends advanced_testcase { + + /** + * Data provider for {@see test_get_sql_filter_simple} + * + * @return array + */ + public function get_sql_filter_simple_provider(): array { + return [ + [select::ANY_VALUE, null, true], + [select::EQUAL_TO, 'starwars', true], + [select::EQUAL_TO, 'mandalorian', false], + [select::NOT_EQUAL_TO, 'starwars', false], + [select::NOT_EQUAL_TO, 'mandalorian', true], + ]; + } + + /** + * Test getting filter SQL + * + * @param int $operator + * @param string|null $value + * @param bool $expectmatch + * + * @dataProvider get_sql_filter_simple_provider + */ + public function test_get_sql_filter_simple(int $operator, ?string $value, bool $expectmatch): void { + global $DB; + + $this->resetAfterTest(); + + $course1 = $this->getDataGenerator()->create_course([ + 'fullname' => "May the course be with you", + 'shortname' => 'starwars', + ]); + $course2 = $this->getDataGenerator()->create_course([ + 'fullname' => "This is the course", + 'shortname' => 'mandalorian', + ]); + + $filter = (new filter( + select::class, + 'test', + new lang_string('course'), + 'testentity', + 'shortname' + ))->set_options([ + $course1->shortname => $course1->fullname, + $course2->shortname => $course2->fullname, + ]); + + // Create instance of our filter, passing given operator. + [$select, $params] = select::create($filter)->get_sql_filter([ + $filter->get_unique_identifier() . '_operator' => $operator, + $filter->get_unique_identifier() . '_value' => $value, + ]); + + $fullnames = $DB->get_fieldset_select('course', 'fullname', $select, $params); + if ($expectmatch) { + $this->assertContains($course1->fullname, $fullnames); + } else { + $this->assertNotContains($course1->fullname, $fullnames); + } + } +} diff --git a/reportbuilder/tests/local/filters/text_test.php b/reportbuilder/tests/local/filters/text_test.php new file mode 100644 index 00000000000..f16b4e0501a --- /dev/null +++ b/reportbuilder/tests/local/filters/text_test.php @@ -0,0 +1,100 @@ +. + +declare(strict_types=1); + +namespace core_reportbuilder\local\filters; + +use advanced_testcase; +use lang_string; +use core_reportbuilder\local\report\filter; + +/** + * Unit tests for text report filter + * + * @package core_reportbuilder + * @covers \core_reportbuilder\local\filters\base + * @covers \core_reportbuilder\local\filters\text + * @copyright 2021 David Matamoros + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class text_testcase extends advanced_testcase { + + /** + * Data provider for {@see test_get_sql_filter_simple} + * + * @return array + */ + public function get_sql_filter_simple_provider(): array { + return [ + [text::ANY_VALUE, null, true], + [text::CONTAINS, 'looking', true], + [text::CONTAINS, 'sky', false], + [text::DOES_NOT_CONTAIN, 'sky', true], + [text::DOES_NOT_CONTAIN, 'looking', false], + [text::IS_EQUAL_TO, "Hello, is it me you're looking for?", true], + [text::IS_EQUAL_TO, 'I can see it in your eyes', false], + [text::IS_NOT_EQUAL_TO, "Hello, is it me you're looking for?", false], + [text::IS_NOT_EQUAL_TO, 'I can see it in your eyes', true], + [text::STARTS_WITH, 'Hello', true], + [text::STARTS_WITH, 'sunlight', false], + [text::ENDS_WITH, 'looking for?', true], + [text::ENDS_WITH, 'your heart', false], + [text::IS_EMPTY, null, false], + [text::IS_NOT_EMPTY, null, true], + ]; + } + + /** + * Test getting filter SQL + * + * @param int $operator + * @param string|null $value + * @param bool $expectmatch + * + * @dataProvider get_sql_filter_simple_provider + */ + public function test_get_sql_filter_simple(int $operator, ?string $value, bool $expectmatch): void { + global $DB; + + $this->resetAfterTest(); + + $course = $this->getDataGenerator()->create_course([ + 'fullname' => "Hello, is it me you're looking for?", + ]); + + $filter = new filter( + text::class, + 'test', + new lang_string('course'), + 'testentity', + 'fullname' + ); + + // Create instance of our filter, passing given operator. + [$select, $params] = text::create($filter)->get_sql_filter([ + $filter->get_unique_identifier() . '_operator' => $operator, + $filter->get_unique_identifier() . '_value' => $value, + ]); + + $fullnames = $DB->get_fieldset_select('course', 'fullname', $select, $params); + if ($expectmatch) { + $this->assertContains($course->fullname, $fullnames); + } else { + $this->assertNotContains($course->fullname, $fullnames); + } + } +} diff --git a/reportbuilder/tests/local/helpers/custom_fields_test.php b/reportbuilder/tests/local/helpers/custom_fields_test.php new file mode 100644 index 00000000000..fcfcb8932a1 --- /dev/null +++ b/reportbuilder/tests/local/helpers/custom_fields_test.php @@ -0,0 +1,133 @@ +. + +declare(strict_types=1); + +namespace core_reportbuilder\local\helpers; + +use advanced_testcase; +use core_reportbuilder\local\entities\course; +use core_reportbuilder\local\report\column; +use core_reportbuilder\local\report\filter; + +/** + * Unit tests for custom fields helper + * + * @package core_reportbuilder + * @covers \core_reportbuilder\local\helpers\custom_fields + * @copyright 2021 David Matamoros + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class custom_fields_testcase extends advanced_testcase { + + /** + * Generate a course with customfields + */ + public function generate_course_with_customfields(): custom_fields { + $course = $this->getDataGenerator()->create_course(); + + // Add some customfields to the course. + $cfgenerator = self::getDataGenerator()->get_plugin_generator('core_customfield'); + $params = [ + 'component' => 'core_course', + 'area' => 'course', + 'itemid' => 0, + 'contextid' => \context_system::instance()->id + ]; + $category = $cfgenerator->create_category($params); + $field1 = $cfgenerator->create_field( + ['categoryid' => $category->get('id'), 'type' => 'text', 'name' => 'Customfield text 1', 'shortname' => 'cf1']); + $cfgenerator->add_instance_data($field1, (int)$course->id, 'C-3PO'); + $field2 = $cfgenerator->create_field( + ['categoryid' => $category->get('id'), 'type' => 'text', 'name' => 'Customfield text 2', 'shortname' => 'cf2']); + $cfgenerator->add_instance_data($field2, (int)$course->id, 'R2-D2'); + + $courseentity = new course(); + $coursealias = $courseentity->get_table_alias('course'); + + // Create an instance of the customfields helper. + return new custom_fields($coursealias . '.id', $courseentity->get_entity_name(), + 'core_course', 'course'); + } + + /** + * Test for get_columns + */ + public function test_get_columns(): void { + $this->resetAfterTest(); + + $customfields = $this->generate_course_with_customfields(); + $columns = $customfields->get_columns(); + $this->assertCount(2, $columns); + [$column0, $column1] = $columns; + $this->assertInstanceOf(column::class, $column0); + $this->assertInstanceOf(column::class, $column1); + $this->assertEqualsCanonicalizing(['Customfield text 1', 'Customfield text 2'], + [$column0->get_title(), $column1->get_title()]); + $this->assertEquals(column::TYPE_TEXT, $column0->get_type()); + $this->assertEquals('course', $column0->get_entity_name()); + $this->assertStringStartsWith('LEFT JOIN {customfield_data}', $column0->get_joins()[0]); + // Column of type TEXT is sortable. + $this->assertTrue($column0->get_is_sortable()); + } + + /** + * Test for add_join + */ + public function test_add_join(): void { + $this->resetAfterTest(); + + $customfields = $this->generate_course_with_customfields(); + $columns = $customfields->get_columns(); + $this->assertCount(1, ($columns[0])->get_joins()); + + $customfields->add_join('JOIN {test} t ON t.id = id'); + $columns = $customfields->get_columns(); + $this->assertCount(2, ($columns[0])->get_joins()); + } + + /** + * Test for add_joins + */ + public function test_add_joins(): void { + $this->resetAfterTest(); + + $customfields = $this->generate_course_with_customfields(); + $columns = $customfields->get_columns(); + $this->assertCount(1, ($columns[0])->get_joins()); + + $customfields->add_joins(['JOIN {test} t ON t.id = id', 'JOIN {test2} t2 ON t2.id = id']); + $columns = $customfields->get_columns(); + $this->assertCount(3, ($columns[0])->get_joins()); + } + + /** + * Test for get_filters + */ + public function test_get_filters(): void { + $this->resetAfterTest(); + + $customfields = $this->generate_course_with_customfields(); + $filters = $customfields->get_filters(); + $this->assertCount(2, $filters); + [$filter0, $filter1] = $filters; + $this->assertInstanceOf(filter::class, $filter0); + $this->assertInstanceOf(filter::class, $filter1); + $this->assertEqualsCanonicalizing(['Customfield text 1', 'Customfield text 2'], + [$filter0->get_header(), $filter1->get_header()]); + } +} + diff --git a/reportbuilder/tests/local/helpers/database_test.php b/reportbuilder/tests/local/helpers/database_test.php new file mode 100644 index 00000000000..24df801539e --- /dev/null +++ b/reportbuilder/tests/local/helpers/database_test.php @@ -0,0 +1,81 @@ +. + +declare(strict_types=1); + +namespace core_reportbuilder\local\helpers; + +use advanced_testcase; +use coding_exception; +use core_user; + +/** + * Unit tests for the database helper class + * + * @package core_reportbuilder + * @covers \core_reportbuilder\local\helpers\database + * @copyright 2020 Paul Holden + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class database_testcase extends advanced_testcase { + + /** + * Test generating table alias and parameter names + */ + public function test_generate_alias_params(): void { + global $DB; + + $admin = core_user::get_user_by_username('admin'); + + $usertablealias = database::generate_alias(); + $usertablealiasjoin = database::generate_alias(); + $useridalias = database::generate_alias(); + + $paramuserid = database::generate_param_name(); + $paramuserdeleted = database::generate_param_name(); + + // Ensure they are different. + $this->assertNotEquals($usertablealias, $usertablealiasjoin); + $this->assertNotEquals($paramuserid, $paramuserdeleted); + + $sql = "SELECT {$usertablealias}.id AS {$useridalias} + FROM {user} {$usertablealias} + JOIN {user} {$usertablealiasjoin} ON {$usertablealiasjoin}.id = {$usertablealias}.id + WHERE {$usertablealias}.id = :{$paramuserid} AND {$usertablealias}.deleted = :{$paramuserdeleted}"; + $params = [$paramuserid => $admin->id, $paramuserdeleted => 0]; + + $validated = database::validate_params($params); + $this->assertTrue($validated); + + $record = $DB->get_record_sql($sql, $params); + $this->assertEquals($admin->id, $record->{$useridalias}); + } + + /** + * Test parameter validation + */ + public function test_validate_params(): void { + $params = [ + database::generate_param_name() => 1, + 'invalidfoo' => 2, + 'invalidbar' => 4, + ]; + + $this->expectException(coding_exception::class); + $this->expectExceptionMessage('Invalid parameter names (invalidfoo, invalidbar)'); + database::validate_params($params); + } +} diff --git a/reportbuilder/tests/local/helpers/format_test.php b/reportbuilder/tests/local/helpers/format_test.php new file mode 100644 index 00000000000..dd45a4cdb24 --- /dev/null +++ b/reportbuilder/tests/local/helpers/format_test.php @@ -0,0 +1,67 @@ +. + +declare(strict_types=1); + +namespace core_reportbuilder\local\helpers; + +use advanced_testcase; +use stdClass; + +/** + * Unit tests for the format helper + * + * @package core_reportbuilder + * @covers \core_reportbuilder\local\helpers\format + * @copyright 2021 Paul Holden + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class format_testcase extends advanced_testcase { + + /** + * Test userdate method + */ + public function test_userdate(): void { + $now = time(); + + $userdate = format::userdate($now, new stdClass()); + $this->assertEquals(userdate($now), $userdate); + } + + /** + * Data provider for {@see test_boolean_as_text} + * + * @return array + */ + public function boolean_as_text_provider(): array { + return [ + [false, get_string('no')], + [true, get_string('yes')], + ]; + } + + /** + * Test boolean as text + * + * @param bool $value + * @param string $expected + * + * @dataProvider boolean_as_text_provider + */ + public function test_boolean_as_text(bool $value, string $expected): void { + $this->assertEquals($expected, format::boolean_as_text($value)); + } +} diff --git a/reportbuilder/tests/local/helpers/user_filter_manager_test.php b/reportbuilder/tests/local/helpers/user_filter_manager_test.php new file mode 100644 index 00000000000..911ab2c3355 --- /dev/null +++ b/reportbuilder/tests/local/helpers/user_filter_manager_test.php @@ -0,0 +1,233 @@ +. + +declare(strict_types=1); + +namespace core_reportbuilder\local\helpers; + +use advanced_testcase; + +/** + * Unit tests for the user filter helper + * + * @package core_reportbuilder + * @covers \core_reportbuilder\local\helpers\user_filter_manager + * @copyright 2021 Paul Holden + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class user_filter_manager_testcase extends advanced_testcase { + + /** + * Helper method to return all user preferences for filters - based on the current storage backend using the same + * + * @return array + */ + private function get_filter_preferences(): array { + return array_filter(get_user_preferences(), static function(string $key): bool { + return strpos($key, 'reportbuilder-report-') === 0; + }, ARRAY_FILTER_USE_KEY); + } + + /** + * Data provider for {@see test_get} + * + * @return array + */ + public function get_provider(): array { + return [ + 'Small value' => ['foo'], + 'Large value' => [str_repeat('A', 4000)], + 'Empty value' => [''], + ]; + } + + /** + * Test getting filter values + * + * @param string $value + * + * @dataProvider get_provider + */ + public function test_get(string $value): void { + $this->resetAfterTest(); + + $values = [ + 'entity:filter_name' => $value, + ]; + user_filter_manager::set(5, $values); + + // Make sure we get the same value back. + $this->assertEquals($values, user_filter_manager::get(5)); + } + + /** + * Test getting filter values that once spanned multiple chunks + */ + public function test_get_large_to_small(): void { + $this->resetAfterTest(); + + // Set a large initial filter value. + user_filter_manager::set(5, [ + 'longvalue' => str_repeat('ABCD', 1000), + ]); + + // Sanity check, there should be 4 (because 4000 characters plus some JSON encoding requires that many chunks). + $preferences = $this->get_filter_preferences(); + $this->assertCount(4, $preferences); + + $values = [ + 'longvalue' => 'ABCD', + ]; + user_filter_manager::set(5, $values); + + // Make sure we get the same value back. + $this->assertEquals($values, user_filter_manager::get(5)); + + // Everything should now fit in a single filter preference. + $preferences = $this->get_filter_preferences(); + $this->assertCount(1, $preferences); + } + + /** + * Test getting filter values that haven't been set + */ + public function test_get_empty(): void { + $this->assertEquals([], user_filter_manager::get(5)); + } + + /** + * Data provider for {@see test_reset_all} + * + * @return array + */ + public function reset_all_provider(): array { + return [ + 'Small value' => ['foo'], + 'Large value' => [str_repeat('A', 4000)], + 'Empty value' => [''], + ]; + } + + /** + * Test resetting all filter values + * + * @param string $value + * + * @dataProvider reset_all_provider + */ + public function test_reset_all(string $value): void { + $this->resetAfterTest(); + + user_filter_manager::set(5, [ + 'entity:filter_name' => $value + ]); + + $reset = user_filter_manager::reset_all(5); + $this->assertTrue($reset); + + // We should get an empty array back. + $this->assertEquals([], user_filter_manager::get(5)); + + // All filter preferences should be removed. + $this->assertEmpty($this->get_filter_preferences()); + } + + /** + * Test resetting single filter values + */ + public function test_reset_single(): void { + $this->resetAfterTest(); + + user_filter_manager::set(5, [ + 'entity:filter_name' => 'foo', + 'entity:filter_value' => 'bar', + 'entity:other_name' => 'baz', + 'entity:other_value' => 'bax', + ]); + + $reset = user_filter_manager::reset_single(5, 'entity:other'); + $this->assertTrue($reset); + + $this->assertEquals([ + 'entity:filter_name' => 'foo', + 'entity:filter_value' => 'bar', + ], user_filter_manager::get(5)); + } + + /** + * Test merging filter values + */ + public function test_merge(): void { + $this->resetAfterTest(); + + $values = [ + 'entity:filter_name' => 'foo', + 'entity:filter_value' => 'bar', + 'entity:filter2_name' => 'tree', + 'entity:filter2_value' => 'house', + ]; + + // Make sure we get the same value back. + user_filter_manager::set(5, $values); + $this->assertEqualsCanonicalizing($values, user_filter_manager::get(5)); + + user_filter_manager::merge(5, [ + 'entity:filter_name' => 'twotimesfoo', + 'entity:filter_value' => 'twotimesbar', + ]); + + // Make sure that both values have been changed and the other values have not been modified. + $expected = [ + 'entity:filter_name' => 'twotimesfoo', + 'entity:filter_value' => 'twotimesbar', + 'entity:filter2_name' => 'tree', + 'entity:filter2_value' => 'house', + ]; + $this->assertEqualsCanonicalizing($expected, user_filter_manager::get(5)); + } + + /** + * Test to get all filters from a given user + */ + public function test_get_all_for_user(): void { + $this->resetAfterTest(); + $user = $this->getDataGenerator()->create_user(); + $this->setUser($user); + + $filtervalues1 = [ + 'entity:filter_name' => 'foo', + 'entity:filter_value' => 'bar', + 'entity:other_name' => 'baz', + 'entity:other_value' => 'bax', + ]; + user_filter_manager::set(5, $filtervalues1); + + $filtervalues2 = [ + 'entity:filter_name' => 'blue', + 'entity:filter_value' => 'red', + ]; + user_filter_manager::set(9, $filtervalues2); + + $this->setAdminUser(); + $values = user_filter_manager::get_all_for_user((int)$user->id); + $this->assertEqualsCanonicalizing([$filtervalues1, $filtervalues2], [reset($values), end($values)]); + + // Check for a user with no filters. + $user2 = $this->getDataGenerator()->create_user(); + $values = user_filter_manager::get_all_for_user((int)$user2->id); + $this->assertEmpty($values); + } +} diff --git a/reportbuilder/tests/local/helpers/user_profile_fields_test.php b/reportbuilder/tests/local/helpers/user_profile_fields_test.php new file mode 100644 index 00000000000..c73f41f470e --- /dev/null +++ b/reportbuilder/tests/local/helpers/user_profile_fields_test.php @@ -0,0 +1,117 @@ +. + +declare(strict_types=1); + +namespace core_reportbuilder\local\helpers; + +use advanced_testcase; +use core_reportbuilder\local\entities\user; +use core_reportbuilder\local\report\column; +use core_reportbuilder\local\report\filter; + +/** + * Unit tests for user profile fields helper + * + * @package core_reportbuilder + * @covers \core_reportbuilder\local\helpers\user_profile_fields + * @copyright 2021 David Matamoros + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class user_profile_fields_test_testcase extends advanced_testcase { + + /** + * Generate userprofilefields + */ + private function generate_userprofilefields(): user_profile_fields { + $this->getDataGenerator()->create_custom_profile_field([ + 'shortname' => 'upf1', 'name' => 'User profile field text 1', 'datatype' => 'text']); + + $this->getDataGenerator()->create_custom_profile_field([ + 'shortname' => 'upf2', 'name' => 'User profile field text 2', 'datatype' => 'text']); + + $userentity = new user(); + $useralias = $userentity->get_table_alias('user'); + + // Create an instance of the userprofilefield helper. + return new user_profile_fields("$useralias.id", $userentity->get_entity_name()); + } + + /** + * Test for get_columns + */ + public function test_get_columns(): void { + $this->resetAfterTest(); + + $userprofilefields = $this->generate_userprofilefields(); + $columns = $userprofilefields->get_columns(); + $this->assertCount(2, $columns); + [$column0, $column1] = $columns; + $this->assertInstanceOf(column::class, $column0); + $this->assertInstanceOf(column::class, $column1); + $this->assertEqualsCanonicalizing(['User profile field text 1', 'User profile field text 2'], + [$column0->get_title(), $column1->get_title()]); + $this->assertEquals(column::TYPE_TEXT, $column0->get_type()); + $this->assertEquals('user', $column0->get_entity_name()); + $this->assertStringStartsWith('LEFT JOIN {user_info_data}', $column0->get_joins()[0]); + } + + /** + * Test for add_join + */ + public function test_add_join(): void { + $this->resetAfterTest(); + + $userprofilefields = $this->generate_userprofilefields(); + $columns = $userprofilefields->get_columns(); + $this->assertCount(1, ($columns[0])->get_joins()); + + $userprofilefields->add_join('JOIN {test} t ON t.id = id'); + $columns = $userprofilefields->get_columns(); + $this->assertCount(2, ($columns[0])->get_joins()); + } + + /** + * Test for add_joins + */ + public function test_add_joins(): void { + $this->resetAfterTest(); + + $userprofilefields = $this->generate_userprofilefields(); + $columns = $userprofilefields->get_columns(); + $this->assertCount(1, ($columns[0])->get_joins()); + + $userprofilefields->add_joins(['JOIN {test} t ON t.id = id', 'JOIN {test2} t2 ON t2.id = id']); + $columns = $userprofilefields->get_columns(); + $this->assertCount(3, ($columns[0])->get_joins()); + } + + /** + * Test for get_filters + */ + public function test_get_filters(): void { + $this->resetAfterTest(); + + $userprofilefields = $this->generate_userprofilefields(); + $filters = $userprofilefields->get_filters(); + $this->assertCount(2, $filters); + [$filter0, $filter1] = $filters; + $this->assertInstanceOf(filter::class, $filter0); + $this->assertInstanceOf(filter::class, $filter1); + $this->assertEqualsCanonicalizing(['User profile field text 1', 'User profile field text 2'], + [$filter0->get_header(), $filter1->get_header()]); + } +} diff --git a/reportbuilder/tests/local/report/action_test.php b/reportbuilder/tests/local/report/action_test.php new file mode 100644 index 00000000000..428c3b65a06 --- /dev/null +++ b/reportbuilder/tests/local/report/action_test.php @@ -0,0 +1,103 @@ +. + +declare(strict_types=1); + +namespace core_reportbuilder\local\report; + +use advanced_testcase; +use moodle_url; +use pix_icon; +use stdClass; + +/** + * Unit tests for a report action + * + * @package core_reportbuilder + * @covers \core_reportbuilder\local\report\action + * @copyright 2021 Paul Holden + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class action_testcase extends advanced_testcase { + + /** + * Test adding a callback that returns true + */ + public function test_add_callback_true(): void { + $action = $this->create_action() + ->add_callback(static function(stdClass $row): bool { + return true; + }); + + $this->assertNotNull($action->get_action_link(new stdClass())); + } + + /** + * Test adding a callback that returns false + */ + public function test_add_callback_false(): void { + $action = $this->create_action() + ->add_callback(static function(stdClass $row): bool { + return false; + }); + + $this->assertNull($action->get_action_link(new stdClass())); + } + + /** + * Test that action link URL parameters have placeholders replaced + */ + public function test_get_action_link_url_parameters(): void { + $action = $this->create_action(['id' => ':id', 'action' => 'edit']); + $actionlink = $action->get_action_link((object) ['id' => 42]); + + // This is the action URL we expect. + $expectedactionurl = (new moodle_url('/', ['id' => 42, 'action' => 'edit']))->out(false); + $this->assertStringContainsString("href=\"{$expectedactionurl}\"", $actionlink); + } + + /** + * Test that action link attributes have placeholders replaced + */ + public function test_get_action_link_attributes(): void { + $action = $this->create_action([], ['data-id' => ':id', 'data-action' => 'edit']); + $actionlink = $action->get_action_link((object) ['id' => 42]); + + // We expect each of these attributes to exist. + $expectedattributes = [ + 'data-id' => 42, + 'data-action' => 'edit', + ]; + foreach ($expectedattributes as $key => $value) { + $this->assertStringContainsString("{$key}=\"{$value}\"", $actionlink); + } + } + + /** + * Helper method to create an action instance + * + * @param array $urlparams + * @param array $attributes + * @return action + */ + private function create_action(array $urlparams = [], array $attributes = []): action { + return new action( + new moodle_url('/', $urlparams), + new pix_icon('t/edit', get_string('edit')), + $attributes + ); + } +} diff --git a/reportbuilder/tests/local/report/base_test.php b/reportbuilder/tests/local/report/base_test.php new file mode 100644 index 00000000000..88be3775556 --- /dev/null +++ b/reportbuilder/tests/local/report/base_test.php @@ -0,0 +1,153 @@ +. + +declare(strict_types=1); + +namespace core_reportbuilder\local\report; + +use advanced_testcase; +use context_system; +use core_reportbuilder\system_report_available; +use core_reportbuilder\system_report_factory; + +/** + * Unit tests for report base class + * + * @package core_reportbuilder + * @covers \core_reportbuilder\local\report\base + * @copyright 2021 David Matamoros + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class base_testcase extends advanced_testcase { + + /** + * Load required class + */ + public static function setUpBeforeClass(): void { + global $CFG; + require_once("{$CFG->dirroot}/reportbuilder/tests/fixtures/system_report_available.php"); + } + + /** + * Test for add_base_condition_simple + */ + public function test_add_base_condition_simple(): void { + $this->resetAfterTest(); + + $systemreport = system_report_factory::create(system_report_available::class, context_system::instance()); + $systemreport->add_base_condition_simple('username', 'admin'); + [$where, $params] = $systemreport->get_base_condition(); + $this->assertStringMatchesFormat('username = :%a', $where); + $this->assertEqualsCanonicalizing(['admin'], $params); + } + + /** + * Test for add_base_condition_simple null + */ + public function test_add_base_condition_simple_null(): void { + $this->resetAfterTest(); + + $systemreport = system_report_factory::create(system_report_available::class, context_system::instance()); + $systemreport->add_base_condition_simple('username', null); + [$where, $params] = $systemreport->get_base_condition(); + $this->assertEquals('username IS NULL', $where); + $this->assertEmpty($params); + } + + /** + * Test for get_filter_instances + */ + public function test_get_filter_instances(): void { + $this->resetAfterTest(); + + $systemreport = system_report_factory::create(system_report_available::class, context_system::instance(), + '', '', 0, ['withfilters' => true]); + $filters = $systemreport->get_filter_instances(); + $this->assertCount(1, $filters); + $this->assertInstanceOf(\core_reportbuilder\local\filters\text::class, reset($filters)); + } + + /** + * Test for set_downloadable + */ + public function test_set_downloadable(): void { + $this->resetAfterTest(); + + $systemreport = system_report_factory::create(system_report_available::class, context_system::instance()); + $systemreport->set_downloadable(true, 'testfilename'); + $this->assertTrue($systemreport->is_downloadable()); + $this->assertEquals('testfilename', $systemreport->get_downloadfilename()); + + $systemreport->set_downloadable(false, 'anothertestfilename'); + $this->assertFalse($systemreport->is_downloadable()); + $this->assertEquals('anothertestfilename', $systemreport->get_downloadfilename()); + } + + /** + * Test for get_context + */ + public function test_get_context(): void { + $this->resetAfterTest(); + + $systemreport = system_report_factory::create(system_report_available::class, context_system::instance()); + $this->assertEquals(context_system::instance(), $systemreport->get_context()); + + $course = $this->getDataGenerator()->create_course(); + $contextcourse = \context_course::instance($course->id); + $systemreport2 = system_report_factory::create(system_report_available::class, $contextcourse); + $this->assertEquals($contextcourse, $systemreport2->get_context()); + } + + /** + * Test for get_column + */ + public function test_get_column(): void { + $this->resetAfterTest(); + + $systemreport = system_report_factory::create(system_report_available::class, context_system::instance()); + $column = $systemreport->get_column('user:username'); + $this->assertInstanceOf(column::class, $column); + + $column = $systemreport->get_column('user:nonexistingcolumn'); + $this->assertNull($column); + } + + /** + * Test for get_filter + */ + public function test_get_filter(): void { + $this->resetAfterTest(); + + $systemreport = system_report_factory::create(system_report_available::class, context_system::instance(), + '', '', 0, ['withfilters' => true]); + $filter = $systemreport->get_filter('user:username'); + $this->assertInstanceOf(filter::class, $filter); + + $filter = $systemreport->get_filter('user:nonexistingfilter'); + $this->assertNull($filter); + } + + /** + * Test for get_report_persistent + */ + public function test_get_report_persistent(): void { + $this->resetAfterTest(); + + $systemreport = system_report_factory::create(system_report_available::class, context_system::instance()); + $persistent = $systemreport->get_report_persistent(); + $this->assertEquals(system_report_available::class, $persistent->get('source')); + } +} diff --git a/reportbuilder/tests/local/report/column_test.php b/reportbuilder/tests/local/report/column_test.php new file mode 100644 index 00000000000..e1f48d6ec2b --- /dev/null +++ b/reportbuilder/tests/local/report/column_test.php @@ -0,0 +1,423 @@ +. + +declare(strict_types=1); + +namespace core_reportbuilder\local\report; + +use advanced_testcase; +use coding_exception; +use lang_string; +use stdClass; +use core_reportbuilder\local\helpers\database; + +/** + * Unit tests for a report column + * + * @package core_reportbuilder + * @covers \core_reportbuilder\local\report\column + * @copyright 2020 Paul Holden + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class column_testcase extends advanced_testcase { + + /** + * Test column name getter/setter + */ + public function test_name(): void { + $column = $this->create_column('test'); + $this->assertEquals('test', $column->get_name()); + + $this->assertEquals('another', $column + ->set_name('another') + ->get_name() + ); + } + + /** + * Test column title getter/setter + */ + public function test_title(): void { + $column = $this->create_column('test', new lang_string('show')); + $this->assertEquals('Show', $column->get_title()); + + $this->assertEquals('Hide', $column + ->set_title(new lang_string('hide')) + ->get_title() + ); + + // Column titles can also be empty. + $this->assertEmpty($column + ->set_title(null) + ->get_title()); + } + + /** + * Test entity name getter + */ + public function test_get_entity_name(): void { + $column = $this->create_column('test', null, 'entityname'); + $this->assertEquals('entityname', $column->get_entity_name()); + } + + /** + * Test getting unique identifier + */ + public function test_get_unique_identifier(): void { + $column = $this->create_column('test', null, 'entityname'); + $this->assertEquals('entityname:test', $column->get_unique_identifier()); + } + + /** + * Test column type getter/setter + */ + public function test_type(): void { + $column = $this->create_column('test'); + $this->assertEquals(column::TYPE_TEXT, $column + ->set_type(column::TYPE_TEXT) + ->get_type()); + } + + /** + * Test column type with invalid value + */ + public function test_type_invalid(): void { + $column = $this->create_column('test'); + + $this->expectException(coding_exception::class); + $this->expectExceptionMessage('Invalid column type'); + $column->set_type(-1); + } + + /** + * Test adding single join + */ + public function test_add_join(): void { + $column = $this->create_column('test'); + $this->assertEquals([], $column->get_joins()); + + $column->add_join('JOIN {user} u ON u.id = table.userid'); + $this->assertEquals(['JOIN {user} u ON u.id = table.userid'], $column->get_joins()); + } + + /** + * Test adding multiple joins + */ + public function test_add_joins(): void { + $tablejoins = [ + "JOIN {course} c2 ON c2.id = c1.id", + "JOIN {course} c3 ON c3.id = c1.id", + ]; + + $column = $this->create_column('test') + ->add_joins($tablejoins); + + $this->assertEquals($tablejoins, $column->get_joins()); + } + + /** + * Data provider for {@see test_add_field} + * + * @return array + */ + public function add_field_provider(): array { + return [ + ['foo', '', ['foo AS c1_foo']], + ['foo', 'bar', ['foo AS c1_bar']], + ['t.foo', '', ['t.foo AS c1_foo']], + ['t.foo', 'bar', ['t.foo AS c1_bar']], + ]; + } + + /** + * Test adding single field, and retrieving it + * + * @param string $sql + * @param string $alias + * @param array $expectedselect + * + * @dataProvider add_field_provider + */ + public function test_add_field(string $sql, string $alias, array $expectedselect): void { + $column = $this->create_column('test') + ->set_index(1) + ->add_field($sql, $alias); + + $this->assertEquals($expectedselect, $column->get_fields()); + } + + /** + * Test adding params to field, and retrieving them + */ + public function test_add_field_with_params(): void { + $param = database::generate_param_name(); + + $column = $this->create_column('test') + ->set_index(1) + ->add_field(":{$param}", 'foo', [$param => 'bar']); + + // Select will look like the following: "p_rbparam", where index is the column index and counter is + // a static value of the report helper class. + $select = $column->get_fields(); + preg_match('/:(?p1_rbparam[\d]+) AS c1_foo/', $select[0], $matches); + + $this->assertArrayHasKey('paramname', $matches); + $this->assertEquals([$matches['paramname'] => 'bar'], $column->get_params()); + } + + /** + * Test adding field with alias as part of SQL throws an exception + */ + public function test_add_field_alias_in_sql(): void { + $column = $this->create_column('test') + ->set_index(1); + + $this->expectException(coding_exception::class); + $this->expectExceptionMessage('Column alias must be passed as a separate argument'); + $column->add_field('foo AS bar'); + } + + /** + * Test adding field with complex SQL without an alias throws an exception + */ + public function test_add_field_complex_without_alias(): void { + global $DB; + + $column = $this->create_column('test') + ->set_index(1); + + $this->expectException(coding_exception::class); + $this->expectExceptionMessage('Complex columns must have an alias'); + $column->add_field($DB->sql_concat('foo', 'bar')); + } + + /** + * Data provider for {@see test_add_fields} + * + * @return array + */ + public function add_fields_provider(): array { + return [ + ['t.foo', ['t.foo AS c1_foo']], + ['t.foo bar', ['t.foo AS c1_bar']], + ['t.foo AS bar', ['t.foo AS c1_bar']], + ['t.foo1, t.foo2 bar, t.foo3 AS baz', ['t.foo1 AS c1_foo1', 't.foo2 AS c1_bar', 't.foo3 AS c1_baz']], + ]; + } + + /** + * Test adding fields to a column, and retrieving them + * + * @param string $sql + * @param array $expectedselect + * + * @dataProvider add_fields_provider + */ + public function test_add_fields(string $sql, array $expectedselect): void { + $column = $this->create_column('test') + ->set_index(1) + ->add_fields($sql); + + $this->assertEquals($expectedselect, $column->get_fields()); + } + + /** + * Test column alias + */ + public function test_column_alias(): void { + $column = $this->create_column('test') + ->set_index(1) + ->add_fields('t.foo, t.bar'); + + $this->assertEquals('c1_foo', $column->get_column_alias()); + } + + /** + * Test alias of column without any fields throws exception + */ + public function test_column_alias_no_fields(): void { + $column = $this->create_column('test'); + + $this->expectException(coding_exception::class); + $this->expectExceptionMessage('Column ' . $column->get_unique_identifier() . ' contains no fields'); + $column->add_field($column->get_column_alias()); + } + + /** + * Data provider for {@see test_format_value} + * + * @return array[] + */ + public function format_value_provider(): array { + return [ + [column::TYPE_INTEGER, 42], + [column::TYPE_TEXT, 'Hello'], + [column::TYPE_TIMESTAMP, HOURSECS], + [column::TYPE_BOOLEAN, 1, true], + [column::TYPE_FLOAT, 1.23], + [column::TYPE_LONGTEXT, 'Amigos'], + ]; + } + + /** + * Test that column value is returned as correctly (value plus type) + * + * @param int $columntype + * @param mixed $value + * @param mixed|null $expected Expected value, or null to indicate it should be identical to value + * + * @dataProvider format_value_provider + */ + public function test_format_value(int $columntype, $value, $expected = null): void { + $column = $this->create_column('test') + ->set_index(1) + ->set_type($columntype) + ->add_field('t.foo'); + + $this->assertSame($expected ?? $value, $column->format_value([ + 'c1_foo' => $value, + ])); + } + + /** + * Test that column value with callback is returned + */ + public function test_format_value_callback(): void { + $column = $this->create_column('test') + ->set_index(1) + ->add_field('t.foo') + ->set_type(column::TYPE_INTEGER) + ->add_callback(static function(int $value, stdClass $values) { + return $value * 2; + }); + + $this->assertEquals(84, $column->format_value([ + 'c1_bar' => 10, + 'c1_foo' => 42, + ])); + } + + /** + * Test that column value with callback (using all fields) is returned + */ + public function test_format_value_callback_fields(): void { + $column = $this->create_column('test') + ->set_index(1) + ->add_fields('t.foo, t.baz') + ->set_type(column::TYPE_INTEGER) + ->add_callback(static function(int $value, stdClass $values) { + return $values->foo + $values->baz; + }); + + $this->assertEquals(60, $column->format_value([ + 'c1_bar' => 10, + 'c1_foo' => 42, + 'c1_baz' => 18, + ])); + } + + /** + * Test that column value with callback (using arguments) is returned + */ + public function test_format_value_callback_arguments(): void { + $column = $this->create_column('test') + ->set_index(1) + ->add_field('t.foo') + ->set_type(column::TYPE_INTEGER) + ->add_callback(static function(int $value, stdClass $values, int $argument) { + return $value - $argument; + }, 10); + + $this->assertEquals(32, $column->format_value([ + 'c1_bar' => 10, + 'c1_foo' => 42, + ])); + } + + /** + * Test adding multiple callbacks to a column + */ + public function test_add_multiple_callback(): void { + $column = $this->create_column('test') + ->set_index(1) + ->add_field('t.foo') + ->set_type(column::TYPE_TEXT) + ->add_callback(static function(string $value): string { + return strrev($value); + }) + ->add_callback(static function(string $value): string { + return strtoupper($value); + }); + + $this->assertEquals('LIONEL', $column->format_value([ + 'c1_foo' => 'lenoil', + ])); + } + + /** + * Test that setting column callback overwrites previous callbacks + */ + public function test_set_callback(): void { + $column = $this->create_column('test') + ->set_index(1) + ->add_field('t.foo') + ->set_type(column::TYPE_TEXT) + ->add_callback(static function(string $value): string { + return strrev($value); + }) + ->set_callback(static function(string $value): string { + return strtoupper($value); + }); + + $this->assertEquals('LENOIL', $column->format_value([ + 'c1_foo' => 'lenoil', + ])); + } + + /** + * Test is sortable + */ + public function test_is_sortable(): void { + $column = $this->create_column('test'); + $this->assertFalse($column->get_is_sortable()); + + $column->set_is_sortable(true); + $this->assertTrue($column->get_is_sortable()); + } + + /** + * Test is available + */ + public function test_is_available(): void { + $column = $this->create_column('test'); + $this->assertTrue($column->get_is_available()); + + $column->set_is_available(true); + $this->assertTrue($column->get_is_available()); + } + + /** + * Helper method to create a column instance + * + * @param string $name + * @param lang_string|null $title + * @param string $entityname + * @return column + */ + private function create_column(string $name, ?lang_string $title = null, string $entityname = 'column_testcase'): column { + return new column($name, $title, $entityname); + } +} diff --git a/reportbuilder/tests/local/report/filter_test.php b/reportbuilder/tests/local/report/filter_test.php new file mode 100644 index 00000000000..c3f22fa8bca --- /dev/null +++ b/reportbuilder/tests/local/report/filter_test.php @@ -0,0 +1,219 @@ +. + +declare(strict_types=1); + +namespace core_reportbuilder\local\report; + +use advanced_testcase; +use lang_string; +use moodle_exception; +use core_reportbuilder\local\filters\text; + +/** + * Unit tests for a report filter + * + * @package core_reportbuilder + * @covers \core_reportbuilder\local\report\filter + * @copyright 2021 Paul Holden + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class filter_testcase extends advanced_testcase { + + /** + * Test getting filter class + */ + public function test_get_filter_class(): void { + $filter = $this->create_filter('username'); + $this->assertEquals(text::class, $filter->get_filter_class()); + } + + /** + * Test specifying invalid filter class + */ + public function test_invalid_filter_class(): void { + $this->expectException(moodle_exception::class); + $this->expectExceptionMessage('Invalid filter (sillyclass)'); + new filter('sillyclass', 'username', new lang_string('username'), 'filter_testcase'); + } + + /** + * Test getting name + */ + public function test_get_name(): void { + $filter = $this->create_filter('username'); + $this->assertEquals('username', $filter->get_name()); + } + + /** + * Test getting header + */ + public function test_get_header(): void { + $filter = $this->create_filter('username'); + $this->assertEquals('Username', $filter->get_header()); + } + + /** + * Test setting header + */ + public function test_set_header(): void { + $filter = $this->create_filter('username') + ->set_header(new lang_string('firstname')); + + $this->assertEquals('First name', $filter->get_header()); + } + + /** + * Test getting entity name + */ + public function test_get_entity_name(): void { + $filter = $this->create_filter('username'); + $this->assertEquals('filter_testcase', $filter->get_entity_name()); + } + + /** + * Test getting unique identifier + */ + public function test_get_unique_identifier(): void { + $filter = $this->create_filter('username'); + $this->assertEquals('filter_testcase:username', $filter->get_unique_identifier()); + } + + /** + * Test getting field SQL + */ + public function test_get_field_sql(): void { + $filter = $this->create_filter('username', 'u.username'); + $this->assertEquals('u.username', $filter->get_field_sql()); + } + + /** + * Test getting field params + */ + public function test_get_field_params(): void { + $filter = $this->create_filter('username', 'u.username = :foo', ['foo' => 'bar']); + $this->assertEquals(['foo' => 'bar'], $filter->get_field_params()); + } + + /** + * Test adding single join + */ + public function test_add_join(): void { + $filter = $this->create_filter('username', 'u.username'); + $this->assertEquals([], $filter->get_joins()); + + $filter->add_join('JOIN {user} u ON u.id = table.userid'); + $this->assertEquals(['JOIN {user} u ON u.id = table.userid'], $filter->get_joins()); + } + + /** + * Test adding multiple joins + */ + public function test_add_joins(): void { + $tablejoins = [ + "JOIN {course} c2 ON c2.id = c1.id", + "JOIN {course} c3 ON c3.id = c1.id", + ]; + + $filter = $this->create_filter('username', 'u.username') + ->add_joins($tablejoins); + + $this->assertEquals($tablejoins, $filter->get_joins()); + } + + /** + * Test is available + */ + public function test_is_available(): void { + $filter = $this->create_filter('username', 'u.username'); + $this->assertTrue($filter->get_is_available()); + + $filter->set_is_available(true); + $this->assertTrue($filter->get_is_available()); + } + + /** + * Test setting filter options + */ + public function test_set_options(): void { + $filter = $this->create_filter('username', 'u.username') + ->set_options([1, 2, 3]); + + $this->assertEquals([1, 2, 3], $filter->get_options()); + } + + /** + * Test setting filter options via callback + */ + public function test_set_options_callback(): void { + $filter = $this->create_filter('username', 'u.username') + ->set_options_callback(static function() { + return 10 * 5; + }); + + $this->assertEquals(50, $filter->get_options()); + } + + /** + * Test restricting filter operators + */ + public function test_limited_operators(): void { + $filter = $this->create_filter('username', 'u.username') + ->set_limited_operators([ + text::IS_EQUAL_TO, + text::IS_NOT_EQUAL_TO, + ]); + + $limitedoperators = $filter->restrict_limited_operators([ + text::CONTAINS => 'Contains', + text::DOES_NOT_CONTAIN => 'Does not contain', + text::IS_EQUAL_TO => 'Is equal to', + text::IS_NOT_EQUAL_TO => 'Is not equal to', + ]); + + $this->assertEquals([ + text::IS_EQUAL_TO => 'Is equal to', + text::IS_NOT_EQUAL_TO => 'Is not equal to', + ], $limitedoperators); + } + + /** + * Test not restricting filter operators + */ + public function test_unlimited_operators(): void { + $filter = $this->create_filter('username', 'u.username'); + + $operators = [ + text::CONTAINS => 'Contains', + text::DOES_NOT_CONTAIN => 'Does not contain', + ]; + + // If no operator limit has been set for the filter, then all available operators should be present. + $this->assertEquals($operators, $filter->restrict_limited_operators($operators)); + } + + /** + * Helper method to create a filter instance + * + * @param string $name + * @param string $fieldsql + * @param array $fieldparams + * @return filter + */ + private function create_filter(string $name, string $fieldsql = '', array $fieldparams = []): filter { + return new filter(text::class, $name, new lang_string($name), 'filter_testcase', $fieldsql, $fieldparams); + } +} diff --git a/reportbuilder/tests/manager_test.php b/reportbuilder/tests/manager_test.php new file mode 100644 index 00000000000..03e85bc6e3c --- /dev/null +++ b/reportbuilder/tests/manager_test.php @@ -0,0 +1,132 @@ +. + +declare(strict_types=1); + +namespace core_reportbuilder; + +use advanced_testcase; +use context_system; +use stdClass; +use core_reportbuilder\local\models\report; +use core_reportbuilder\local\report\base; + +/** + * Unit tests for the report manager class + * + * @package core_reportbuilder + * @covers \core_reportbuilder\manager + * @copyright 2020 Paul Holden + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class manager_testcase extends advanced_testcase { + + /** + * Test creating a report instance from persistent + */ + public function test_get_report_from_persistent(): void { + global $CFG; + require_once("{$CFG->dirroot}/reportbuilder/tests/fixtures/system_report_available.php"); + + $this->resetAfterTest(); + + $report = manager::create_report_persistent((object) [ + 'type' => base::TYPE_SYSTEM_REPORT, + 'source' => system_report_available::class, + ]); + + $systemreport = manager::get_report_from_persistent($report); + $this->assertInstanceOf(system_report::class, $systemreport); + } + + /** + * Test creating a report instance from persistent with an invalid source + */ + public function test_get_report_from_persistent_invalid(): void { + $this->resetAfterTest(); + + $report = manager::create_report_persistent((object) [ + 'type' => base::TYPE_SYSTEM_REPORT, + 'source' => stdClass::class, + ]); + + $this->expectException(source_invalid_exception::class); + manager::get_report_from_persistent($report); + } + + /** + * Test creating a report instance from persistent with an unavailable source + */ + public function test_get_report_from_persistent_unavailable(): void { + global $CFG; + require_once("{$CFG->dirroot}/reportbuilder/tests/fixtures/system_report_unavailable.php"); + + $this->resetAfterTest(); + + $report = manager::create_report_persistent((object) [ + 'type' => base::TYPE_SYSTEM_REPORT, + 'source' => system_report_unavailable::class, + ]); + + $this->expectException(source_unavailable_exception::class); + manager::get_report_from_persistent($report); + } + + /** + * Test report source exists + */ + public function test_report_source_exists(): void { + global $CFG; + + require_once("{$CFG->dirroot}/reportbuilder/tests/fixtures/system_report_available.php"); + $this->assertTrue(manager::report_source_exists(system_report_available::class)); + + $this->assertFalse(manager::report_source_exists(stdClass::class)); + } + + /** + * Test report source available + */ + public function test_report_source_available(): void { + global $CFG; + + require_once("{$CFG->dirroot}/reportbuilder/tests/fixtures/system_report_available.php"); + $this->assertTrue(manager::report_source_available(system_report_available::class)); + + require_once("{$CFG->dirroot}/reportbuilder/tests/fixtures/system_report_unavailable.php"); + $this->assertFalse(manager::report_source_available(system_report_unavailable::class)); + } + + /** + * Test creating a report persistent model + */ + public function test_create_report_persistent(): void { + global $CFG; + require_once("{$CFG->dirroot}/reportbuilder/tests/fixtures/system_report_available.php"); + + $this->resetAfterTest(); + + $report = manager::create_report_persistent((object) [ + 'type' => base::TYPE_SYSTEM_REPORT, + 'source' => \core_reportbuilder\system_report_available::class, + ]); + + $this->assertInstanceOf(report::class, $report); + $this->assertEquals(base::TYPE_SYSTEM_REPORT, $report->get('type')); + $this->assertEquals(system_report_available::class, $report->get('source')); + $this->assertInstanceOf(context_system::class, $report->get_context()); + } +} diff --git a/reportbuilder/tests/privacy/provider_test.php b/reportbuilder/tests/privacy/provider_test.php new file mode 100644 index 00000000000..34f54b9212b --- /dev/null +++ b/reportbuilder/tests/privacy/provider_test.php @@ -0,0 +1,112 @@ +. + +declare(strict_types=1); + +namespace core_reportbuilder\privacy; + +use context_system; +use core_privacy\local\metadata\collection; +use core_privacy\local\metadata\types\database_table; +use core_privacy\local\metadata\types\user_preference; +use core_privacy\local\request\writer; +use core_reportbuilder\manager; +use core_reportbuilder\local\helpers\user_filter_manager; + +/** + * Unit tests for privacy provider + * + * @package core_reportbuilder + * @covers \core_reportbuilder\privacy\provider + * @copyright 2021 David Matamoros + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class provider_testcase extends \core_privacy\tests\provider_testcase { + + /** + * Test provider metadata + */ + public function test_get_metadata(): void { + $collection = new collection('core_reportbuilder'); + $metadata = provider::get_metadata($collection)->get_collection(); + + $this->assertCount(2, $metadata); + + $this->assertInstanceOf(database_table::class, $metadata[0]); + $this->assertEquals('reportbuilder_report', $metadata[0]->get_name()); + + $this->assertInstanceOf(user_preference::class, $metadata[1]); + } + + /** + * Test to check export_user_preferences. + */ + public function test_export_user_preferences(): void { + $this->resetAfterTest(); + + $user1 = $this->getDataGenerator()->create_user(); + $user2 = $this->getDataGenerator()->create_user(); + $this->setUser($user1); + + // Create report and set some filters for the user. + $report1 = manager::create_report_persistent((object) [ + 'type' => 1, + 'source' => 'class', + ]); + $filtervalues1 = [ + 'task_log:name_operator' => 0, + 'task_log:name_value' => 'My task logs', + ]; + user_filter_manager::set($report1->get('id'), $filtervalues1); + + // Add a filter for user2. + $filtervalues1user2 = [ + 'task_log:name_operator' => 0, + 'task_log:name_value' => 'My task logs user2', + ]; + user_filter_manager::set($report1->get('id'), $filtervalues1user2, (int)$user2->id); + + // Create a second report and set some filters for the user. + $report2 = manager::create_report_persistent((object) [ + 'type' => 1, + 'source' => 'class', + ]); + $filtervalues2 = [ + 'config_change:setting_operator' => 0, + 'config_change:setting_value' => str_repeat('A', 3000), + ]; + user_filter_manager::set($report2->get('id'), $filtervalues2); + + // Switch to admin user (so we can validate preferences of our test user are still exported). + $this->setAdminUser(); + + // Export user preferences. + provider::export_user_preferences((int)$user1->id); + $writer = writer::with_context(context_system::instance()); + $prefs = $writer->get_user_preferences('core_reportbuilder'); + + // Check that user preferences only contain the 2 preferences from user1. + $this->assertCount(2, (array)$prefs); + + // Check that exported user preferences for report1 are correct. + $report1key = 'reportbuilder-report-' . $report1->get('id'); + $this->assertEquals(json_encode($filtervalues1, JSON_PRETTY_PRINT), $prefs->$report1key->value); + + // Check that exported user preferences for report2 are correct. + $report2key = 'reportbuilder-report-' . $report2->get('id'); + $this->assertEquals(json_encode($filtervalues2, JSON_PRETTY_PRINT), $prefs->$report2key->value); + } +} diff --git a/reportbuilder/tests/system_report_factory_test.php b/reportbuilder/tests/system_report_factory_test.php new file mode 100644 index 00000000000..3adfd069ff9 --- /dev/null +++ b/reportbuilder/tests/system_report_factory_test.php @@ -0,0 +1,85 @@ +. + +declare(strict_types=1); + +namespace core_reportbuilder; + +use advanced_testcase; +use context_system; +use stdClass; + +/** + * Unit tests for the system report factory class + * + * @package core_reportbuilder + * @covers \core_reportbuilder\system_report_factory + * @copyright 2020 Paul Holden + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class system_report_factory_testcase extends advanced_testcase { + + /** + * Test creating a valid/available system report + */ + public function test_create(): void { + global $CFG; + require_once("{$CFG->dirroot}/reportbuilder/tests/fixtures/system_report_available.php"); + + $this->resetAfterTest(); + + $systemreport = system_report_factory::create(system_report_available::class, context_system::instance()); + $this->assertInstanceOf(system_report::class, $systemreport); + } + + /** + * Test that creating a system report returns the same persistent if it already exists + */ + public function test_create_previously_exists(): void { + global $CFG; + require_once("{$CFG->dirroot}/reportbuilder/tests/fixtures/system_report_available.php"); + + $this->resetAfterTest(); + + $systemreportone = system_report_factory::create(system_report_available::class, context_system::instance()); + $systemreporttwo = system_report_factory::create(system_report_available::class, context_system::instance()); + + // Assert we have the same persistent for each. + $this->assertEquals($systemreportone->get_report_persistent()->get('id'), + $systemreporttwo->get_report_persistent()->get('id')); + } + + /** + * Test creating an system report with an invalid source + */ + public function test_create_invalid(): void { + $this->expectException(source_invalid_exception::class); + system_report_factory::create(stdClass::class, context_system::instance()); + } + + /** + * Test creating an unavailable system report + */ + public function test_create_unavailable(): void { + global $CFG; + require_once("{$CFG->dirroot}/reportbuilder/tests/fixtures/system_report_unavailable.php"); + + $this->resetAfterTest(); + + $this->expectException(source_unavailable_exception::class); + system_report_factory::create(system_report_unavailable::class, context_system::instance()); + } +} diff --git a/reportbuilder/tests/system_report_test.php b/reportbuilder/tests/system_report_test.php new file mode 100644 index 00000000000..577749101b7 --- /dev/null +++ b/reportbuilder/tests/system_report_test.php @@ -0,0 +1,94 @@ +. + +declare(strict_types=1); + +namespace core_reportbuilder; + +use advanced_testcase; +use context_system; +use core_reportbuilder\local\report\action; + +/** + * Unit tests for the system report class + * + * @package core_reportbuilder + * @covers \core_reportbuilder\system_report + * @copyright 2021 David Matamoros + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class system_report_testcase extends advanced_testcase { + /** + * Test for actions + */ + public function test_actions(): void { + global $CFG; + require_once("{$CFG->dirroot}/reportbuilder/tests/fixtures/system_report_available.php"); + + $this->resetAfterTest(); + + $systemreport = system_report_factory::create(system_report_available::class, context_system::instance(), + '', '', 0, ['withactions' => true]); + $actions = $systemreport->get_actions(); + $this->assertCount(1, $actions); + $action = reset($actions); + $this->assertInstanceOf(action::class, $action); + + $systemreport->add_action($action); + $actions = $systemreport->get_actions(); + $this->assertCount(2, $actions); + $this->assertTrue($systemreport->has_actions()); + } + + /** + * Test for parameters + */ + public function test_parameters(): void { + global $CFG; + require_once("{$CFG->dirroot}/reportbuilder/tests/fixtures/system_report_available.php"); + + $this->resetAfterTest(); + + $systemreport = system_report_factory::create(system_report_available::class, context_system::instance(), + '', '', 0, ['withactions' => true]); + + $this->assertEquals(['withactions' => true], $systemreport->get_parameters()); + + $systemreport->set_parameters(['withfilters' => true, 'secondparameter' => false]); + $this->assertEquals(['withfilters' => true, 'secondparameter' => false], $systemreport->get_parameters()); + + $this->assertFalse((bool)$systemreport->get_parameter('secondparameter', true, PARAM_BOOL)); + // Get a param that does not exist. + $this->assertEquals('3', $systemreport->get_parameter('thirdparameter', '3', PARAM_INT)); + } + + /** + * Test for column sorting + */ + public function test_initial_sort_column(): void { + global $CFG; + require_once("{$CFG->dirroot}/reportbuilder/tests/fixtures/system_report_available.php"); + + $this->resetAfterTest(); + + $systemreport = system_report_factory::create(system_report_available::class, context_system::instance()); + $systemreport->set_initial_sort_column('user:username', SORT_DESC); + $column = $systemreport->get_column('user:username'); + + $this->assertEquals($column, $systemreport->get_initial_sort_column()); + $this->assertEquals(SORT_DESC, $systemreport->get_initial_sort_direction()); + } +} diff --git a/theme/boost/scss/moodle.scss b/theme/boost/scss/moodle.scss index 5066ae560bd..9eb49651a86 100644 --- a/theme/boost/scss/moodle.scss +++ b/theme/boost/scss/moodle.scss @@ -43,3 +43,4 @@ $breadcrumb-divider-rtl: "◀" !default; @import "moodle/atto"; @import "moodle/toasts"; @import "moodle/navbar"; +@import "moodle/reportbuilder"; diff --git a/theme/boost/scss/moodle/reportbuilder.scss b/theme/boost/scss/moodle/reportbuilder.scss new file mode 100644 index 00000000000..e82df1e72da --- /dev/null +++ b/theme/boost/scss/moodle/reportbuilder.scss @@ -0,0 +1,46 @@ +/* Rportbuilder */ +.reportbuilder-table-wrapper { + .filters-dropdown { + width: 27rem; + padding: 0; + @include media-breakpoint-down(sm) { + width: 100%; + } + } + .reportbuilder-filters-wrapper { + .mform { + &.full-width-labels { + .fitem.row { + > .col-md-3, + > .col-md-9 { + flex: 0 0 100%; + max-width: 100%; + } + .fdate_selector { + flex-wrap: wrap; + } + } + } + .form-group { + margin-bottom: 0; + } + } + .filter { + .filter-header { + font-size: $h5-font-size; + .filter-name { + font-size: 1rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + padding-right: 1rem; + } + .filter-name:hover { + white-space: normal; + text-overflow: clip; + word-break: break-all; + } + } + } + } +} diff --git a/theme/boost/style/moodle.css b/theme/boost/style/moodle.css index 2bcfc669f2c..4866cf964f2 100644 --- a/theme/boost/style/moodle.css +++ b/theme/boost/style/moodle.css @@ -20017,6 +20017,38 @@ div.editor_atto_toolbar button .icon { #page { margin-top: 50px; } +/* Rportbuilder */ +.reportbuilder-table-wrapper .filters-dropdown { + width: 27rem; + padding: 0; } + @media (max-width: 767.98px) { + .reportbuilder-table-wrapper .filters-dropdown { + width: 100%; } } + +.reportbuilder-table-wrapper .reportbuilder-filters-wrapper .mform.full-width-labels .fitem.row > .col-md-3, +.reportbuilder-table-wrapper .reportbuilder-filters-wrapper .mform.full-width-labels .fitem.row > .col-md-9 { + flex: 0 0 100%; + max-width: 100%; } + +.reportbuilder-table-wrapper .reportbuilder-filters-wrapper .mform.full-width-labels .fitem.row .fdate_selector { + flex-wrap: wrap; } + +.reportbuilder-table-wrapper .reportbuilder-filters-wrapper .mform .form-group { + margin-bottom: 0; } + +.reportbuilder-table-wrapper .reportbuilder-filters-wrapper .filter .filter-header { + font-size: 1.171875rem; } + .reportbuilder-table-wrapper .reportbuilder-filters-wrapper .filter .filter-header .filter-name { + font-size: 1rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + padding-right: 1rem; } + .reportbuilder-table-wrapper .reportbuilder-filters-wrapper .filter .filter-header .filter-name:hover { + white-space: normal; + text-overflow: clip; + word-break: break-all; } + body { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; } diff --git a/theme/classic/style/moodle.css b/theme/classic/style/moodle.css index 60c4f73915d..54acfb4b797 100644 --- a/theme/classic/style/moodle.css +++ b/theme/classic/style/moodle.css @@ -20208,6 +20208,38 @@ div.editor_atto_toolbar button .icon { #page { margin-top: 50px; } +/* Rportbuilder */ +.reportbuilder-table-wrapper .filters-dropdown { + width: 27rem; + padding: 0; } + @media (max-width: 767.98px) { + .reportbuilder-table-wrapper .filters-dropdown { + width: 100%; } } + +.reportbuilder-table-wrapper .reportbuilder-filters-wrapper .mform.full-width-labels .fitem.row > .col-md-3, +.reportbuilder-table-wrapper .reportbuilder-filters-wrapper .mform.full-width-labels .fitem.row > .col-md-9 { + flex: 0 0 100%; + max-width: 100%; } + +.reportbuilder-table-wrapper .reportbuilder-filters-wrapper .mform.full-width-labels .fitem.row .fdate_selector { + flex-wrap: wrap; } + +.reportbuilder-table-wrapper .reportbuilder-filters-wrapper .mform .form-group { + margin-bottom: 0; } + +.reportbuilder-table-wrapper .reportbuilder-filters-wrapper .filter .filter-header { + font-size: 1.171875rem; } + .reportbuilder-table-wrapper .reportbuilder-filters-wrapper .filter .filter-header .filter-name { + font-size: 1rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + padding-right: 1rem; } + .reportbuilder-table-wrapper .reportbuilder-filters-wrapper .filter .filter-header .filter-name:hover { + white-space: normal; + text-overflow: clip; + word-break: break-all; } + body { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; } diff --git a/version.php b/version.php index 923dd8dabce..84de88665ef 100644 --- a/version.php +++ b/version.php @@ -29,7 +29,7 @@ defined('MOODLE_INTERNAL') || die(); -$version = 2021072800.00; // YYYYMMDD = weekly release date of this DEV branch. +$version = 2021072800.01; // YYYYMMDD = weekly release date of this DEV branch. // RR = release increments - 00 in DEV branches. // .XX = incremental changes. $release = '4.0dev (Build: 20210728)'; // Human-friendly version name