MDL-70565 user: filter course participants by country.

This commit is contained in:
Paul Holden 2021-02-24 10:37:18 +00:00
parent fc335f5ea0
commit be2862fe6f
7 changed files with 284 additions and 2 deletions

View File

@ -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<b.length;c++){d=b[c];d.enumerable=d.enumerable||!1;d.configurable=!0;if("value"in d)d.writable=!0;Object.defineProperty(a,d.key,d)}}function f(a,b,c){if(b)e(a.prototype,b);if(c)e(a,c);return a}function g(a,b){if("function"!=typeof b&&null!==b){throw new TypeError("Super expression must either be null or a function")}a.prototype=Object.create(b&&b.prototype,{constructor:{value:a,writable:!0,configurable:!0}});if(b)h(a,b)}function h(a,b){h=Object.setPrototypeOf||function(a,b){a.__proto__=b;return a};return h(a,b)}function i(a){return function(){var b=m(a),c;if(l()){var d=m(this).constructor;c=Reflect.construct(b,arguments,d)}else{c=b.apply(this,arguments)}return j(this,c)}}function j(a,b){if(b&&("object"===c(b)||"function"==typeof b)){return b}return k(a)}function k(a){if(void 0===a){throw new ReferenceError("this hasn't been initialised - super() hasn't been called")}return a}function l(){if("undefined"==typeof Reflect||!Reflect.construct)return!1;if(Reflect.construct.sham)return!1;if("function"==typeof Proxy)return!0;try{Date.prototype.toString.call(Reflect.construct(Date,[],function(){}));return!0}catch(a){return!1}}function m(a){m=Object.setPrototypeOf?Object.getPrototypeOf:function(a){return a.__proto__||Object.getPrototypeOf(a)};return m(a)}var n=function(a){g(b,a);var c=i(b);function b(){d(this,b);return c.apply(this,arguments)}f(b,[{key:"values",get:function get(){return this.rawValues}}]);return b}(b.default);a.default=n;return a.default});
//# sourceMappingURL=country.min.js.map

View File

@ -0,0 +1 @@
{"version":3,"sources":["../../../../src/local/participantsfilter/filtertypes/country.js"],"names":["rawValues","Filter"],"mappings":"sLAwBA,uD,2vDASiB,CACT,MAAO,MAAKA,SACf,C,cATwBC,S","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 <http://www.gnu.org/licenses/>.\n\n/**\n * Country filter\n *\n * @module core_user/local/participantsfilter/filtertypes/country\n * @package core_user\n * @copyright 2021 Paul Holden <paulh@moodle.com>\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"}

View File

@ -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 <http://www.gnu.org/licenses/>.
/**
* Country filter
*
* @module core_user/local/participantsfilter/filtertypes/country
* @package core_user
* @copyright 2021 Paul Holden <paulh@moodle.com>
* @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;
}
}

View File

@ -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.
*

View File

@ -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,
];

View File

@ -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.

View File

@ -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.
*