diff --git a/user/amd/build/local/participantsfilter/filtertypes/country.min.js b/user/amd/build/local/participantsfilter/filtertypes/country.min.js new file mode 100644 index 00000000000..982e4fb0812 --- /dev/null +++ b/user/amd/build/local/participantsfilter/filtertypes/country.min.js @@ -0,0 +1,2 @@ +define ("core_user/local/participantsfilter/filtertypes/country",["exports","../filter"],function(a,b){"use strict";Object.defineProperty(a,"__esModule",{value:!0});a.default=void 0;b=function(a){return a&&a.__esModule?a:{default:a}}(b);function c(a){"@babel/helpers - typeof";if("function"==typeof Symbol&&"symbol"==typeof Symbol.iterator){c=function(a){return typeof a}}else{c=function(a){return a&&"function"==typeof Symbol&&a.constructor===Symbol&&a!==Symbol.prototype?"symbol":typeof a}}return c(a)}function d(a,b){if(!(a instanceof b)){throw new TypeError("Cannot call a class as a function")}}function e(a,b){for(var c=0,d;c.\n\n/**\n * Country filter\n *\n * @module core_user/local/participantsfilter/filtertypes/country\n * @package core_user\n * @copyright 2021 Paul Holden \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\nimport Filter from '../filter';\n\nexport default class extends Filter {\n\n /**\n * For country the final value is an array of country code strings\n *\n * @return {Object}\n */\n get values() {\n return this.rawValues;\n }\n}\n"],"file":"country.min.js"} \ No newline at end of file diff --git a/user/amd/src/local/participantsfilter/filtertypes/country.js b/user/amd/src/local/participantsfilter/filtertypes/country.js new file mode 100644 index 00000000000..2c60065435b --- /dev/null +++ b/user/amd/src/local/participantsfilter/filtertypes/country.js @@ -0,0 +1,37 @@ +// 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 . + +/** + * Country filter + * + * @module core_user/local/participantsfilter/filtertypes/country + * @package core_user + * @copyright 2021 Paul Holden + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +import Filter from '../filter'; + +export default class extends Filter { + + /** + * For country the final value is an array of country code strings + * + * @return {Object} + */ + get values() { + return this.rawValues; + } +} diff --git a/user/classes/output/participants_filter.php b/user/classes/output/participants_filter.php index 31406cd53e9..895f095e338 100644 --- a/user/classes/output/participants_filter.php +++ b/user/classes/output/participants_filter.php @@ -24,6 +24,7 @@ namespace core_user\output; use context_course; +use core\user_fields; use renderable; use renderer_base; use stdClass; @@ -89,6 +90,10 @@ class participants_filter implements renderable, templatable { $filtertypes[] = $filtertype; } + if ($filtertype = $this->get_country_filter()) { + $filtertypes[] = $filtertype; + } + return $filtertypes; } @@ -322,6 +327,34 @@ class participants_filter implements renderable, templatable { ); } + /** + * Get data for the country filter + * + * @return stdClass|null + */ + protected function get_country_filter(): ?stdClass { + $extrauserfields = user_fields::get_identity_fields($this->context, false); + if (array_search('country', $extrauserfields) === false) { + return null; + } + + $countries = get_string_manager()->get_list_of_countries(true); + + return $this->get_filter_object( + 'country', + get_string('country'), + false, + true, + 'core_user/local/participantsfilter/filtertypes/country', + array_map(function(string $code, string $name): stdClass { + return (object) [ + 'value' => $code, + 'title' => $name, + ]; + }, array_keys($countries), array_values($countries)) + ); + } + /** * Get data for the keywords filter. * diff --git a/user/classes/table/participants_filterset.php b/user/classes/table/participants_filterset.php index e0d0efe0658..a94b9cf9459 100644 --- a/user/classes/table/participants_filterset.php +++ b/user/classes/table/participants_filterset.php @@ -27,7 +27,6 @@ declare(strict_types=1); namespace core_user\table; -use core_table\local\filter\boolean_filter; use core_table\local\filter\filterset; use core_table\local\filter\integer_filter; use core_table\local\filter\string_filter; @@ -61,6 +60,7 @@ class participants_filterset extends filterset { * - enrolments; * - groups; * - keywords; + * - country; * - roles; and * - status. * @@ -72,6 +72,7 @@ class participants_filterset extends filterset { 'enrolments' => integer_filter::class, 'groups' => integer_filter::class, 'keywords' => string_filter::class, + 'country' => string_filter::class, 'roles' => integer_filter::class, 'status' => integer_filter::class, ]; diff --git a/user/classes/table/participants_search.php b/user/classes/table/participants_search.php index 754c42ec0ba..e931d89290e 100644 --- a/user/classes/table/participants_search.php +++ b/user/classes/table/participants_search.php @@ -257,6 +257,22 @@ class participants_search { } } + // Apply any country filtering. + if ($this->filterset->has_filter('country')) { + [ + 'where' => $countrywhere, + 'params' => $countryparams, + ] = $this->get_country_sql(); + + if (!empty($countrywhere)) { + $wheres[] = "($countrywhere)"; + } + + if (!empty($countryparams)) { + $params = array_merge($params, $countryparams); + } + } + // Apply any keyword text searches. if ($this->filterset->has_filter('keywords')) { [ @@ -877,6 +893,32 @@ class participants_search { ]; } + /** + * Prepare SQL where clause and associated parameters for country filtering + * + * @return array SQL query data in the format ['where' => '', 'params' => []]. + */ + protected function get_country_sql(): array { + global $DB; + + $where = ''; + $params = []; + + $countryfilter = $this->filterset->get_filter('country'); + if ($countrycodes = $countryfilter->get_filter_values()) { + // If filter type is "None", then we negate the comparison. + [$countrywhere, $params] = $DB->get_in_or_equal($countrycodes, SQL_PARAMS_NAMED, 'country', + $countryfilter->get_join_type() !== $countryfilter::JOINTYPE_NONE); + + $where = "(u.country {$countrywhere})"; + } + + return [ + 'where' => $where, + 'params' => $params, + ]; + } + /** * Prepare SQL where clause and associated parameters for any keyword searches being performed. * @@ -975,7 +1017,7 @@ class participants_search { $extrasearchfields = user_fields::get_identity_fields(null); foreach ($extrasearchfields as $fieldindex => $extrasearchfield) { if (in_array($extrasearchfield, ['email', 'idnumber', 'country'])) { - // Already covered above. Search by country not supported. + // Already covered above. continue; } // The param must be short (max 32 characters) so don't include field name. diff --git a/user/tests/table/participants_search_test.php b/user/tests/table/participants_search_test.php index c95cd085845..b9a2b5fb75b 100644 --- a/user/tests/table/participants_search_test.php +++ b/user/tests/table/participants_search_test.php @@ -757,6 +757,172 @@ class participants_search_test extends advanced_testcase { return $finaltests; } + /** + * Test participant search country filter + * + * @param array $usersdata + * @param array $countries + * @param int $jointype + * @param array $expectedusers + * + * @dataProvider country_provider + */ + public function test_country_filter(array $usersdata, array $countries, int $jointype, array $expectedusers): void { + $this->resetAfterTest(); + + $course = $this->getDataGenerator()->create_course(); + $users = []; + + foreach ($usersdata as $username => $country) { + $users[$username] = $this->getDataGenerator()->create_and_enrol($course, 'student', (object) [ + 'username' => $username, + 'country' => $country, + ]); + } + + // Add filters (courseid is required). + $filterset = new participants_filterset(); + $filterset->add_filter(new integer_filter('courseid', null, [(int) $course->id])); + $filterset->add_filter(new string_filter('country', $jointype, $countries)); + + // Run the search, assert count matches the number of expected users. + $search = new participants_search($course, context_course::instance($course->id), $filterset); + $this->assertEquals(count($expectedusers), $search->get_total_participants_count()); + + $rs = $search->get_participants(); + $this->assertInstanceOf(moodle_recordset::class, $rs); + + // Assert that each expected user is within the participant records. + $records = $this->convert_recordset_to_array($rs); + foreach ($expectedusers as $expecteduser) { + $this->assertArrayHasKey($users[$expecteduser]->id, $records); + } + } + + /** + * Data provider for {@see test_country_filter} + * + * @return array + */ + public function country_provider(): array { + $tests = [ + 'users' => [ + 'user1' => 'DE', + 'user2' => 'ES', + 'user3' => 'ES', + 'user4' => 'GB', + ], + 'expects' => [ + // Tests for jointype: ANY. + 'ANY: No filter' => (object) [ + 'countries' => [], + 'jointype' => filter::JOINTYPE_ANY, + 'expectedusers' => [ + 'user1', + 'user2', + 'user3', + 'user4', + ], + ], + 'ANY: Matching filters' => (object) [ + 'countries' => [ + 'DE', + 'GB', + ], + 'jointype' => filter::JOINTYPE_ANY, + 'expectedusers' => [ + 'user1', + 'user4', + ], + ], + 'ANY: Non-matching filters' => (object) [ + 'countries' => [ + 'RU', + ], + 'jointype' => filter::JOINTYPE_ANY, + 'expectedusers' => [], + ], + + // Tests for jointype: ALL. + 'ALL: No filter' => (object) [ + 'countries' => [], + 'jointype' => filter::JOINTYPE_ALL, + 'expectedusers' => [ + 'user1', + 'user2', + 'user3', + 'user4', + ], + ], + 'ALL: Matching filters' => (object) [ + 'countries' => [ + 'DE', + 'GB', + ], + 'jointype' => filter::JOINTYPE_ALL, + 'expectedusers' => [ + 'user1', + 'user4', + ], + ], + 'ALL: Non-matching filters' => (object) [ + 'countries' => [ + 'RU', + ], + 'jointype' => filter::JOINTYPE_ALL, + 'expectedusers' => [], + ], + + // Tests for jointype: NONE. + 'NONE: No filter' => (object) [ + 'countries' => [], + 'jointype' => filter::JOINTYPE_NONE, + 'expectedusers' => [ + 'user1', + 'user2', + 'user3', + 'user4', + ], + ], + 'NONE: Matching filters' => (object) [ + 'countries' => [ + 'DE', + 'GB', + ], + 'jointype' => filter::JOINTYPE_NONE, + 'expectedusers' => [ + 'user2', + 'user3', + ], + ], + 'NONE: Non-matching filters' => (object) [ + 'countries' => [ + 'RU', + ], + 'jointype' => filter::JOINTYPE_NONE, + 'expectedusers' => [ + 'user1', + 'user2', + 'user3', + 'user4', + ], + ], + ], + ]; + + $finaltests = []; + foreach ($tests['expects'] as $testname => $test) { + $finaltests[$testname] = [ + 'users' => $tests['users'], + 'countries' => $test->countries, + 'jointype' => $test->jointype, + 'expectedusers' => $test->expectedusers, + ]; + } + + return $finaltests; + } + /** * Ensure that the keywords filter works as expected with the provided test cases. *