From ab670ab2861154ac8d2a4a18c4fda62a376b0c42 Mon Sep 17 00:00:00 2001 From: Paul Holden Date: Wed, 18 Jan 2023 17:24:14 +0000 Subject: [PATCH] MDL-76933 badges: create user-focused datasource for custom reporting. This report source differs from the original badges source (5274ee5a) by making `user` the primary table of the report, allowing for reports on all users regardless of whether they have been awarded badges. --- .../reportbuilder/datasource/users.php | 137 ++++++++ .../reportbuilder/datasource/users_test.php | 310 ++++++++++++++++++ lang/en/badges.php | 1 + 3 files changed, 448 insertions(+) create mode 100644 badges/classes/reportbuilder/datasource/users.php create mode 100644 badges/tests/reportbuilder/datasource/users_test.php diff --git a/badges/classes/reportbuilder/datasource/users.php b/badges/classes/reportbuilder/datasource/users.php new file mode 100644 index 0000000000000..22cb74e94e5a2 --- /dev/null +++ b/badges/classes/reportbuilder/datasource/users.php @@ -0,0 +1,137 @@ +. + +declare(strict_types=1); + +namespace core_badges\reportbuilder\datasource; + +use core_reportbuilder\datasource; +use core_reportbuilder\local\entities\{course, user}; +use core_reportbuilder\local\helpers\database; +use core_badges\reportbuilder\local\entities\{badge, badge_issued}; + +/** + * User badges datasource + * + * @package core_badges + * @copyright 2023 Paul Holden + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class users extends datasource { + + /** + * Return user friendly name of the report source + * + * @return string + */ + public static function get_name(): string { + return get_string('userbadges', 'core_badges'); + } + + /** + * Initialise report + */ + protected function initialise(): void { + global $CFG; + + $userentity = new user(); + + $useralias = $userentity->get_table_alias('user'); + $this->set_main_table('user', $useralias); + + $paramguest = database::generate_param_name(); + $this->add_base_condition_sql("{$useralias}.id != :{$paramguest} AND {$useralias}.deleted = 0", [ + $paramguest => $CFG->siteguest, + ]); + + $this->add_entity($userentity); + + // Join the badge issued entity to the user entity. + $badgeissuedentity = new badge_issued(); + $badgeissuedalias = $badgeissuedentity->get_table_alias('badge_issued'); + $this->add_entity($badgeissuedentity + ->add_join("LEFT JOIN {badge_issued} {$badgeissuedalias} ON {$badgeissuedalias}.userid = {$useralias}.id")); + + $badgeentity = new badge(); + $badgealias = $badgeentity->get_table_alias('badge'); + $this->add_entity($badgeentity + ->add_joins($badgeissuedentity->get_joins()) + ->add_join("LEFT JOIN {badge} {$badgealias} ON {$badgealias}.id = {$badgeissuedalias}.badgeid")); + + // Join the course entity to the badge entity, coalescing courseid with the siteid for site badges. + $courseentity = new course(); + $coursealias = $courseentity->get_table_alias('course'); + $this->add_entity($courseentity + ->add_joins($badgeentity->get_joins()) + ->add_join("LEFT JOIN {course} {$coursealias} ON {$coursealias}.id = + CASE WHEN {$badgealias}.id IS NULL THEN 0 ELSE COALESCE({$badgealias}.courseid, 1) END")); + + // Add report elements from each of the entities we added to the report. + $this->add_all_from_entities(); + } + + /** + * Return the columns that will be added to the report upon creation + * + * @return string[] + */ + public function get_default_columns(): array { + return [ + 'user:fullname', + 'badge:name', + 'badge:description', + 'badge_issued:issued', + ]; + } + + /** + * Return the column sorting that will be added to the report upon creation + * + * @return int[] + */ + public function get_default_column_sorting(): array { + return [ + 'user:fullname' => SORT_ASC, + 'badge:name' => SORT_ASC, + 'badge_issued:issued' => SORT_ASC, + ]; + } + + /** + * Return the filters that will be added to the report upon creation + * + * @return string[] + */ + public function get_default_filters(): array { + return [ + 'user:fullname', + 'badge:name', + 'badge_issued:issued', + ]; + } + + /** + * Return the conditions that will be added to the report upon creation + * + * @return string[] + */ + public function get_default_conditions(): array { + return [ + 'badge:type', + 'badge:name', + ]; + } +} diff --git a/badges/tests/reportbuilder/datasource/users_test.php b/badges/tests/reportbuilder/datasource/users_test.php new file mode 100644 index 0000000000000..4cc8611e02bdb --- /dev/null +++ b/badges/tests/reportbuilder/datasource/users_test.php @@ -0,0 +1,310 @@ +. + +declare(strict_types=1); + +namespace core_badges\reportbuilder\datasource; + +use award_criteria; +use core_badges_generator; +use core_reportbuilder_generator; +use core_reportbuilder_testcase; +use core_reportbuilder\local\filters\{boolean_select, date, select, text}; + +defined('MOODLE_INTERNAL') || die(); + +global $CFG; +require_once("{$CFG->dirroot}/reportbuilder/tests/helpers.php"); +require_once("{$CFG->libdir}/badgeslib.php"); + +/** + * Unit tests for user badges datasource + * + * @package core_badges + * @covers \core_badges\reportbuilder\datasource\users + * @copyright 2023 Paul Holden + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class users_test extends core_reportbuilder_testcase { + + /** + * Test default datasource + */ + public function test_datasource_default(): void { + $this->resetAfterTest(); + + $user = $this->getDataGenerator()->create_user(['firstname' => 'Zoe', 'lastname' => 'Zebra']); + + /** @var core_badges_generator $generator */ + $generator = $this->getDataGenerator()->get_plugin_generator('core_badges'); + ($badgeone = $generator->create_badge(['name' => 'Badge 1', 'description' => 'My first badge'])) + ->issue($user->id, true); + ($badgetwo = $generator->create_badge(['name' => 'Badge 2', 'description' => 'My second badge'])) + ->issue($user->id, true); + + /** @var core_reportbuilder_generator $generator */ + $generator = $this->getDataGenerator()->get_plugin_generator('core_reportbuilder'); + $report = $generator->create_report(['name' => 'Badges', 'source' => users::class, 'default' => 1]); + + $content = $this->get_custom_report_content($report->get('id')); + $this->assertCount(3, $content); + + // Default columns are user, badge, description, issued time. Sorted by user, badge, issued time. + [$userfullname, $badgename, $badgedescription, $badgeissued] = array_values($content[0]); + $this->assertEquals('Admin User', $userfullname); + $this->assertEmpty($badgename); + $this->assertEmpty($badgedescription); + $this->assertEmpty($badgeissued); + + [$userfullname, $badgename, $badgedescription, $badgeissued] = array_values($content[1]); + $this->assertEquals(fullname($user), $userfullname); + $this->assertEquals($badgeone->name, $badgename); + $this->assertEquals($badgeone->description, $badgedescription); + $this->assertNotEmpty($badgeissued); + + [$userfullname, $badgename, $badgedescription, $badgeissued] = array_values($content[2]); + $this->assertEquals(fullname($user), $userfullname); + $this->assertEquals($badgetwo->name, $badgename); + $this->assertEquals($badgetwo->description, $badgedescription); + $this->assertNotEmpty($badgeissued); + } + + /** + * Test datasource columns that aren't added by default + */ + public function test_datasource_non_default_columns(): void { + global $DB; + + $this->resetAfterTest(); + + $course = $this->getDataGenerator()->create_course(); + $user = $this->getDataGenerator()->create_and_enrol($course, 'student', ['firstname' => 'Zoe', 'lastname' => 'Zebra']); + + /** @var core_badges_generator $generator */ + $generator = $this->getDataGenerator()->get_plugin_generator('core_badges'); + ($badgesite = $generator->create_badge(['name' => 'Badge 1', 'language' => 'de', 'expireperiod' => HOURSECS])) + ->issue($user->id, true); + ($badgecourse = $generator->create_badge(['name' => 'Badge 2', 'type' => BADGE_TYPE_COURSE, 'courseid' => $course->id])) + ->issue($user->id, true); + + // Overall, plus specific criteria for manually awarding by role. + award_criteria::build(['badgeid' => $badgesite->id, 'criteriatype' => BADGE_CRITERIA_TYPE_OVERALL]) + ->save(['agg' => BADGE_CRITERIA_AGGREGATION_ALL]); + + $managerrole = $DB->get_field('role', 'id', ['shortname' => 'manager']); + award_criteria::build(['badgeid' => $badgesite->id, 'criteriatype' => BADGE_CRITERIA_TYPE_MANUAL]) + ->save(["role_{$managerrole}" => $managerrole]); + + /** @var core_reportbuilder_generator $generator */ + $generator = $this->getDataGenerator()->get_plugin_generator('core_reportbuilder'); + $report = $generator->create_report(['name' => 'Badges', 'source' => users::class, 'default' => 0]); + + // These two columns have been asserted previously, we're only adding them for consistent sorting. + $generator->create_column(['reportid' => $report->get('id'), 'uniqueidentifier' => 'user:fullname', 'sortenabled' => 1]); + $generator->create_column(['reportid' => $report->get('id'), 'uniqueidentifier' => 'badge:name', 'sortenabled' => 1]); + + $generator->create_column(['reportid' => $report->get('id'), 'uniqueidentifier' => 'badge:criteria']); + $generator->create_column(['reportid' => $report->get('id'), 'uniqueidentifier' => 'badge:image']); + $generator->create_column(['reportid' => $report->get('id'), 'uniqueidentifier' => 'badge:language']); + $generator->create_column(['reportid' => $report->get('id'), 'uniqueidentifier' => 'badge:version']); + $generator->create_column(['reportid' => $report->get('id'), 'uniqueidentifier' => 'badge:status']); + $generator->create_column(['reportid' => $report->get('id'), 'uniqueidentifier' => 'badge:expiry']); + + $generator->create_column(['reportid' => $report->get('id'), 'uniqueidentifier' => 'badge_issued:expire']); + $generator->create_column(['reportid' => $report->get('id'), 'uniqueidentifier' => 'badge_issued:visible']); + + $generator->create_column(['reportid' => $report->get('id'), 'uniqueidentifier' => 'course:fullname']); + + $content = $this->get_custom_report_content($report->get('id')); + $this->assertCount(3, $content); + + // Admin user, no badge issued. + [, , $criteria, $image, $language, $version, $status, $expiry, $expires, $visible, $coursename] = array_values($content[0]); + $this->assertEmpty($criteria); + $this->assertEmpty($image); + $this->assertEmpty($language); + $this->assertEmpty($version); + $this->assertEmpty($status); + $this->assertEmpty($expiry); + $this->assertEmpty($expires); + $this->assertEmpty($visible); + $this->assertEmpty($coursename); + + // Site badge issued. + [, , $criteria, $image, $language, $version, $status, $expiry, $expires, $visible, $coursename] = array_values($content[1]); + $this->assertStringContainsString('Awarded by: Manager', $criteria); + $this->assertStringContainsString('Image caption', $image); + $this->assertEquals('German', $language); + $this->assertEquals(2, $version); + $this->assertEquals('Available (criteria locked)', $status); + $this->assertEquals('1 hour', $expiry); + $this->assertNotEmpty($expires); + $this->assertEquals('Yes', $visible); + $this->assertEquals('PHPUnit test site', $coursename); + + // Course badge issued. + [, , $criteria, $image, $language, $version, $status, $expiry, $expires, $visible, $coursename] = array_values($content[2]); + $this->assertEquals('Criteria for this badge have not been set up yet.', $criteria); + $this->assertStringContainsString('Image caption', $image); + $this->assertEquals('English', $language); + $this->assertEquals(2, $version); + $this->assertEquals('Available (criteria locked)', $status); + $this->assertEquals('Never', $expiry); + $this->assertEmpty($expires); + $this->assertEquals('Yes', $visible); + $this->assertEquals($course->fullname, $coursename); + } + + /** + * Data provider for {@see test_datasource_filters} + * + * @return array[] + */ + public function datasource_filters_provider(): array { + return [ + // Badge. + 'Filter badge name' => ['badge:name', [ + 'badge:name_operator' => text::IS_EQUAL_TO, + 'badge:name_value' => 'Course badge', + ], true], + 'Filter badge name (no match)' => ['badge:name', [ + 'badge:name_operator' => text::IS_EQUAL_TO, + 'badge:name_value' => 'Other badge', + ], false], + 'Filter badge status' => ['badge:status', [ + 'badge:status_operator' => select::EQUAL_TO, + 'badge:status_value' => BADGE_STATUS_ACTIVE_LOCKED, + ], true], + 'Filter badge status (no match)' => ['badge:status', [ + 'badge:status_operator' => select::EQUAL_TO, + 'badge:status_value' => BADGE_STATUS_ACTIVE, + ], false], + 'Filter badge type' => ['badge:type', [ + 'badge:type_operator' => select::EQUAL_TO, + 'badge:type_value' => BADGE_TYPE_COURSE, + ], true], + 'Filter badge type (no match)' => ['badge:type', [ + 'badge:type_operator' => select::EQUAL_TO, + 'badge:type_value' => BADGE_TYPE_SITE, + ], false], + + // Badge issued. + 'Filter badge issued date' => ['badge_issued:issued', [ + 'badge_issued:issued_operator' => date::DATE_RANGE, + 'badge_issued:issued_from' => 1622502000, + ], true], + 'Filter badge issued date (no match)' => ['badge_issued:issued', [ + 'badge_issued:issued_operator' => date::DATE_RANGE, + 'badge_issued:issued_to' => 1622502000, + ], false], + 'Filter badge issued expires' => ['badge_issued:expires', [ + 'badge_issued:expires_operator' => date::DATE_RANGE, + 'badge_issued:expires_from' => 1622502000, + ], true], + 'Filter badge issued expires (no match)' => ['badge_issued:expires', [ + 'badge_issued:expires_operator' => date::DATE_RANGE, + 'badge_issued:expires_to' => 1622502000, + ], false], + 'Filter badge issued visible' => ['badge_issued:visible', [ + 'badge_issued:visible_operator' => boolean_select::CHECKED, + ], true], + 'Filter badge issued visible (no match)' => ['badge_issued:visible', [ + 'badge_issued:visible_operator' => boolean_select::NOT_CHECKED, + ], false], + + // Course. + 'Filter course fullname' => ['course:fullname', [ + 'course:fullname_operator' => text::IS_EQUAL_TO, + 'course:fullname_value' => 'Course 1', + ], true], + 'Filter course fullname (no match)' => ['course:fullname', [ + 'course:fullname_operator' => text::IS_EQUAL_TO, + 'course:fullname_value' => 'Course 2', + ], false], + ]; + } + + /** + * Test datasource filters + * + * @param string $filtername + * @param array $filtervalues + * @param bool $expectmatch + * + * @dataProvider datasource_filters_provider + */ + public function test_datasource_filters(string $filtername, array $filtervalues, bool $expectmatch): void { + $this->resetAfterTest(); + + $course = $this->getDataGenerator()->create_course(['fullname' => 'Course 1']); + $user = $this->getDataGenerator()->create_and_enrol($course); + + /** @var core_badges_generator $generator */ + $generator = $this->getDataGenerator()->get_plugin_generator('core_badges'); + $generator->create_badge([ + 'name' => 'Course badge', + 'type' => BADGE_TYPE_COURSE, + 'courseid' => $course->id, + 'expireperiod' => HOURSECS, + ])->issue($user->id, true); + + /** @var core_reportbuilder_generator $generator */ + $generator = $this->getDataGenerator()->get_plugin_generator('core_reportbuilder'); + + // Create report containing single username column, and given filter. + $report = $generator->create_report(['name' => 'My report', 'source' => users::class, 'default' => 0]); + $generator->create_column(['reportid' => $report->get('id'), 'uniqueidentifier' => 'user:username']); + + // Add filter, set it's values. + $generator->create_filter(['reportid' => $report->get('id'), 'uniqueidentifier' => $filtername]); + $content = $this->get_custom_report_content($report->get('id'), 0, $filtervalues); + + if ($expectmatch) { + $this->assertCount(1, $content); + $this->assertEquals($user->username, reset($content[0])); + } else { + $this->assertEmpty($content); + } + } + + /** + * Stress test datasource + * + * In order to execute this test PHPUNIT_LONGTEST should be defined as true in phpunit.xml or directly in config.php + */ + public function test_stress_datasource(): void { + if (!PHPUNIT_LONGTEST) { + $this->markTestSkipped('PHPUNIT_LONGTEST is not defined'); + } + + $this->resetAfterTest(); + + $course = $this->getDataGenerator()->create_course(); + $user = $this->getDataGenerator()->create_and_enrol($course); + + /** @var core_badges_generator $generator */ + $generator = $this->getDataGenerator()->get_plugin_generator('core_badges'); + $generator->create_badge([ + 'name' => 'Course badge', + 'type' => BADGE_TYPE_COURSE, + 'courseid' => $course->id, + ])->issue($user->id, true); + + $this->datasource_stress_test_columns(users::class); + $this->datasource_stress_test_columns_aggregation(users::class); + $this->datasource_stress_test_conditions(users::class, 'user:username'); + } +} diff --git a/lang/en/badges.php b/lang/en/badges.php index 3f4b415d50428..a9970f2935e9d 100644 --- a/lang/en/badges.php +++ b/lang/en/badges.php @@ -557,6 +557,7 @@ $string['testbackpack'] = 'Test backpack \'{$a}\''; $string['testsettings'] = 'Test settings'; $string['type'] = 'Type'; +$string['userbadges'] = 'User badges'; $string['variablesubstitution'] = 'Variable substitution in messages.'; $string['variablesubstitution_help'] = 'In a badge message, certain variables can be inserted into the subject and/or body of a message so that they will be replaced with real values when the message is sent. The variables should be inserted into the text exactly as they are shown below. The following variables can be used: