From db5c663fb9e8d44bb53e97625527ea2cee4c23b4 Mon Sep 17 00:00:00 2001 From: Paul Holden Date: Mon, 16 Jan 2023 14:11:09 +0000 Subject: [PATCH] MDL-76902 course: create categories datasource for custom reporting. New report source based on the course category entity, joined to the course, cohort and role assignment/user entities to provide data for the reportbuilder editor. --- .../reportbuilder/local/entities/cohort.php | 34 ++- .../reportbuilder/datasource/categories.php | 134 ++++++++++ .../local/entities/course_category.php | 13 +- .../datasource/categories_test.php | 253 ++++++++++++++++++ lang/en/course.php | 1 + 5 files changed, 430 insertions(+), 5 deletions(-) create mode 100644 course/classes/reportbuilder/datasource/categories.php create mode 100644 course/tests/reportbuilder/datasource/categories_test.php diff --git a/cohort/classes/reportbuilder/local/entities/cohort.php b/cohort/classes/reportbuilder/local/entities/cohort.php index 04d0797c37c..f7dc9964836 100644 --- a/cohort/classes/reportbuilder/local/entities/cohort.php +++ b/cohort/classes/reportbuilder/local/entities/cohort.php @@ -111,11 +111,15 @@ class cohort extends base { $this->get_entity_name() )) ->add_joins($this->get_joins()) - ->add_join("JOIN {context} {$contextalias} ON {$contextalias}.id = {$tablealias}.contextid") + ->add_join($this->get_context_join()) ->set_type(column::TYPE_TEXT) ->add_fields("{$tablealias}.contextid, " . context_helper::get_preload_record_columns_sql($contextalias)) ->set_is_sortable(true) ->add_callback(static function($contextid, stdClass $cohort): string { + if ($contextid === null) { + return ''; + } + context_helper::preload_from_record($cohort); return context::instance_by_id($cohort->contextid)->get_context_name(false); }); @@ -153,7 +157,7 @@ class cohort extends base { $this->get_entity_name() )) ->add_joins($this->get_joins()) - ->add_join("JOIN {context} {$contextalias} ON {$contextalias}.id = {$tablealias}.contextid") + ->add_join($this->get_context_join()) ->set_type(column::TYPE_LONGTEXT) ->add_field($descriptionfieldsql, 'description') ->add_fields("{$tablealias}.descriptionformat, {$tablealias}.id, {$tablealias}.contextid") @@ -221,8 +225,12 @@ class cohort extends base { ->set_type(column::TYPE_TEXT) ->add_fields("{$tablealias}.component") ->set_is_sortable(true) - ->add_callback(static function(string $component): string { - return empty($component) + ->add_callback(static function(?string $component): string { + if ($component === null) { + return ''; + } + + return $component === '' ? get_string('nocomponent', 'cohort') : get_string('pluginname', $component); }); @@ -321,4 +329,22 @@ class cohort extends base { return $filters; } + + /** + * Return context join used by columns + * + * @return string + */ + private function get_context_join(): string { + + // If the context table is already joined, we don't need to do that again. + if ($this->has_table_join_alias('context')) { + return ''; + } + + $tablealias = $this->get_table_alias('cohort'); + $contextalias = $this->get_table_alias('context'); + + return "LEFT JOIN {context} {$contextalias} ON {$contextalias}.id = {$tablealias}.contextid"; + } } diff --git a/course/classes/reportbuilder/datasource/categories.php b/course/classes/reportbuilder/datasource/categories.php new file mode 100644 index 00000000000..6957d759de9 --- /dev/null +++ b/course/classes/reportbuilder/datasource/categories.php @@ -0,0 +1,134 @@ +. + +declare(strict_types=1); + +namespace core_course\reportbuilder\datasource; + +use core_cohort\reportbuilder\local\entities\cohort; +use core_course\reportbuilder\local\entities\course_category; +use core_reportbuilder\datasource; +use core_reportbuilder\local\entities\{course, user}; +use core_role\reportbuilder\local\entities\role; + +/** + * Course categories datasource + * + * @package core_course + * @copyright 2023 Paul Holden + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class categories extends datasource { + + /** + * Return user friendly name of the datasource + * + * @return string + */ + public static function get_name(): string { + return get_string('coursecategories'); + } + + /** + * Initialise report + */ + protected function initialise(): void { + $categoryentity = new course_category(); + + $categoryalias = $categoryentity->get_table_alias('course_categories'); + $contextalias = $categoryentity->get_table_alias('context'); + + $this->set_main_table('course_categories', $categoryalias); + $this->add_entity($categoryentity); + + // Join course entity. + $courseentity = new course(); + $coursealias = $courseentity->get_table_alias('course'); + $this->add_entity($courseentity + ->add_join("LEFT JOIN {course} {$coursealias} ON {$coursealias}.category = {$categoryalias}.id")); + + // Join cohort entity (amend alias to avoid clash with course entity, indicate context table join alias). + $cohortentity = (new cohort()) + ->set_table_alias('cohort', 'ch') + ->set_table_join_alias('context', $contextalias); + $this->add_entity($cohortentity + ->add_join($categoryentity->get_context_join()) + ->add_join("LEFT JOIN {cohort} ch ON ch.contextid = {$contextalias}.id")); + + // Join role entity. + $roleentity = (new role()) + ->set_table_alias('context', $contextalias); + $role = $roleentity->get_table_alias('role'); + $this->add_entity($roleentity + ->add_join($categoryentity->get_context_join()) + ->add_join("LEFT JOIN {role_assignments} ras ON ras.contextid = {$contextalias}.id") + ->add_join("LEFT JOIN {role} {$role} ON {$role}.id = ras.roleid")); + + // Join user entity. + $userentity = new user(); + $user = $userentity->get_table_alias('user'); + $this->add_entity($userentity + ->add_joins($roleentity->get_joins()) + ->add_join("LEFT JOIN {user} {$user} ON {$user}.id = ras.userid")); + + // Add all elements from entities to be available in custom reports. + $this->add_all_from_entities(); + } + + /** + * Return the columns that will be added to the report as part of default setup + * + * @return string[] + */ + public function get_default_columns(): array { + return [ + 'course_category:name', + 'course_category:idnumber', + 'course_category:coursecount', + ]; + } + + /** + * Return the default sorting that will be added to the report as part of default setup + * + * @return int[] + */ + public function get_default_column_sorting(): array { + return [ + 'course_category:name' => SORT_ASC, + ]; + } + + /** + * Return the filters that will be added to the report as part of default setup + * + * @return string[] + */ + public function get_default_filters(): array { + return [ + 'course_category:name', + ]; + } + + /** + * Return the conditions that will be added to the report as part of default setup + * + * @return string[] + */ + public function get_default_conditions(): array { + return []; + } +} diff --git a/course/classes/reportbuilder/local/entities/course_category.php b/course/classes/reportbuilder/local/entities/course_category.php index 6b2d6360aee..54e02e52210 100644 --- a/course/classes/reportbuilder/local/entities/course_category.php +++ b/course/classes/reportbuilder/local/entities/course_category.php @@ -199,6 +199,17 @@ class course_category extends base { return format_text($description, $category->descriptionformat, ['context' => $context->id]); }); + // Course count column. + $columns[] = (new column( + 'coursecount', + new lang_string('coursecount', 'core_course'), + $this->get_entity_name() + )) + ->add_joins($this->get_joins()) + ->set_type(column::TYPE_INTEGER) + ->add_fields("{$tablealias}.coursecount") + ->set_is_sortable(true); + return $columns; } @@ -251,7 +262,7 @@ class course_category extends base { * * @return string */ - private function get_context_join(): string { + public function get_context_join(): string { $coursecategories = $this->get_table_alias('course_categories'); $context = $this->get_table_alias('context'); return "LEFT JOIN {context} {$context} ON {$context}.instanceid = {$coursecategories}.id diff --git a/course/tests/reportbuilder/datasource/categories_test.php b/course/tests/reportbuilder/datasource/categories_test.php new file mode 100644 index 00000000000..53e121922b2 --- /dev/null +++ b/course/tests/reportbuilder/datasource/categories_test.php @@ -0,0 +1,253 @@ +. + +declare(strict_types=1); + +namespace core_course\reportbuilder\datasource; + +use core_reportbuilder_generator; +use core_reportbuilder_testcase; +use core_reportbuilder\local\filters\{select, text}; + +defined('MOODLE_INTERNAL') || die(); + +global $CFG; +require_once("{$CFG->dirroot}/reportbuilder/tests/helpers.php"); + +/** + * Unit tests for course categories datasource + * + * @package core_course + * @covers \core_course\reportbuilder\datasource\categories + * @copyright 2023 Paul Holden + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class categories_test extends core_reportbuilder_testcase { + + /** + * Test default datasource + */ + public function test_datasource_default(): void { + $this->resetAfterTest(); + + $category = $this->getDataGenerator()->create_category(['name' => 'Zoo', 'idnumber' => 'Z01']); + $course = $this->getDataGenerator()->create_course(['category' => $category->id]); + + /** @var core_reportbuilder_generator $generator */ + $generator = $this->getDataGenerator()->get_plugin_generator('core_reportbuilder'); + $report = $generator->create_report(['name' => 'My report', 'source' => categories::class, 'default' => 1]); + + $content = $this->get_custom_report_content($report->get('id')); + $this->assertCount(2, $content); + + // Default columns are name, idnumber, coursecount. Sorted by name descending. + $this->assertEquals([ + [get_string('defaultcategoryname'), '', 0], + [$category->get_formatted_name(), $category->idnumber, 1], + ], array_map('array_values', $content)); + } + + /** + * Test datasource columns that aren't added by default + */ + public function test_datasource_non_default_columns(): void { + global $DB; + + $this->resetAfterTest(); + + $category = $this->getDataGenerator()->create_category(['name' => 'Zoo', 'idnumber' => 'Z01', 'description' => 'Animals']); + $course = $this->getDataGenerator()->create_course(['category' => $category->id, 'fullname' => 'Zebra']); + + // Add a cohort. + $cohort = $this->getDataGenerator()->create_cohort(['contextid' => $category->get_context()->id, 'name' => 'My cohort']); + + // Assign a role. + $managerrole = $DB->get_field('role', 'id', ['shortname' => 'manager']); + $user = $this->getDataGenerator()->create_user(); + role_assign($managerrole, $user->id, $category->get_context()->id); + + /** @var core_reportbuilder_generator $generator */ + $generator = $this->getDataGenerator()->get_plugin_generator('core_reportbuilder'); + $report = $generator->create_report(['name' => 'My report', 'source' => categories::class, 'default' => 0]); + + $generator->create_column(['reportid' => $report->get('id'), 'uniqueidentifier' => 'course_category:namewithlink', + 'sortenabled' => 1]); + $generator->create_column(['reportid' => $report->get('id'), 'uniqueidentifier' => 'course_category:path']); + $generator->create_column(['reportid' => $report->get('id'), 'uniqueidentifier' => 'course_category:description']); + + // Add column from each of our entities. + $generator->create_column(['reportid' => $report->get('id'), 'uniqueidentifier' => 'course:fullname']); + $generator->create_column(['reportid' => $report->get('id'), 'uniqueidentifier' => 'cohort:name']); + $generator->create_column(['reportid' => $report->get('id'), 'uniqueidentifier' => 'role:name']); + $generator->create_column(['reportid' => $report->get('id'), 'uniqueidentifier' => 'user:fullname']); + + $content = $this->get_custom_report_content($report->get('id')); + $this->assertCount(2, $content); + + [$namewithlink, $path, $description, $coursename, $cohortname, $rolename, $userfullname] = array_values($content[0]); + $this->assertStringContainsString(get_string('defaultcategoryname'), $namewithlink); + $this->assertEquals(get_string('defaultcategoryname'), $path); + $this->assertEmpty($description); + $this->assertEmpty($coursename); + $this->assertEmpty($cohortname); + $this->assertEmpty($rolename); + $this->assertEmpty($userfullname); + + [$namewithlink, $path, $description, $coursename, $cohortname, $rolename, $userfullname] = array_values($content[1]); + $this->assertStringContainsString($category->get_formatted_name(), $namewithlink); + $this->assertEquals($category->get_nested_name(false), $path); + $this->assertEquals(format_text($category->description, $category->descriptionformat), $description); + $this->assertEquals($course->fullname, $coursename); + $this->assertEquals($cohort->name, $cohortname); + $this->assertEquals('Manager', $rolename); + $this->assertEquals(fullname($user), $userfullname); + } + + /** + * Data provider for {@see test_datasource_filters} + * + * @return array[] + */ + public static function datasource_filters_provider(): array { + global $DB; + + return [ + // Category. + 'Filter category (no match)' => ['course_category:name', [ + 'course_category:name_value' => -1, + ], false], + 'Filter category name' => ['course_category:text', [ + 'course_category:text_operator' => text::IS_EQUAL_TO, + 'course_category:text_value' => 'Zoo', + ], true], + 'Filter category name (no match)' => ['course_category:text', [ + 'course_category:text_operator' => text::IS_EQUAL_TO, + 'course_category:text_value' => 'Plants', + ], false], + 'Filter category idnumber' => ['course_category:idnumber', [ + 'course_category:idnumber_operator' => text::IS_EQUAL_TO, + 'course_category:idnumber_value' => 'Z01', + ], true], + 'Filter category idnumber (no match)' => ['course_category:idnumber', [ + 'course_category:idnumber_operator' => text::IS_EQUAL_TO, + 'course_category:idnumber_value' => 'P01', + ], false], + + // Course. + 'Filter course fullname' => ['course:fullname', [ + 'course:fullname_operator' => text::IS_EQUAL_TO, + 'course:fullname_value' => 'Zebra', + ], true], + 'Filter course fullname (no match)' => ['course:fullname', [ + 'course:fullname_operator' => text::IS_EQUAL_TO, + 'course:fullname_value' => 'Python', + ], false], + + // Cohort. + 'Filter cohort name' => ['cohort:name', [ + 'cohort:name_operator' => text::IS_EQUAL_TO, + 'cohort:name_value' => 'My cohort', + ], true], + 'Filter cohort name (no match)' => ['cohort:name', [ + 'cohort:name_operator' => text::IS_EQUAL_TO, + 'cohort:name_value' => 'Not my cohort', + ], false], + + // Role. + 'Filter role' => ['role:name', [ + 'role:name_operator' => select::EQUAL_TO, + 'role:name_value' => $DB->get_field('role', 'id', ['shortname' => 'manager']), + ], true], + 'Filter role (no match)' => ['role:name', [ + 'role:name_operator' => select::EQUAL_TO, + 'role:name_value' => -1, + ], false], + + // User. + 'Filter user firstname' => ['user:firstname', [ + 'user:firstname_operator' => text::IS_EQUAL_TO, + 'user:firstname_value' => 'Zoe', + ], true], + 'Filter user firstname (no match)' => ['user:firstname', [ + 'user:firstname_operator' => text::IS_EQUAL_TO, + 'user:firstname_value' => 'Pedro', + ], 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 { + global $DB; + + $this->resetAfterTest(); + + $category = $this->getDataGenerator()->create_category(['name' => 'Zoo', 'idnumber' => 'Z01']); + $course = $this->getDataGenerator()->create_course(['category' => $category->id, 'fullname' => 'Zebra']); + + // Add a cohort. + $cohort = $this->getDataGenerator()->create_cohort(['contextid' => $category->get_context()->id, 'name' => 'My cohort']); + + // Assign a role. + $managerrole = $DB->get_field('role', 'id', ['shortname' => 'manager']); + $user = $this->getDataGenerator()->create_user(['firstname' => 'Zoe']); + role_assign($managerrole, $user->id, $category->get_context()->id); + + /** @var core_reportbuilder_generator $generator */ + $generator = $this->getDataGenerator()->get_plugin_generator('core_reportbuilder'); + + // Create report containing single idnumber column, and given filter. + $report = $generator->create_report(['name' => 'My report', 'source' => categories::class, 'default' => 0]); + $generator->create_column(['reportid' => $report->get('id'), 'uniqueidentifier' => 'course_category:idnumber']); + + // 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($category->idnumber, 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(); + + $category = $this->getDataGenerator()->create_category(['name' => 'My category']); + + $this->datasource_stress_test_columns(categories::class); + $this->datasource_stress_test_columns_aggregation(categories::class); + $this->datasource_stress_test_conditions(categories::class, 'course_category:idnumber'); + } +} diff --git a/lang/en/course.php b/lang/en/course.php index 8f2bbdfcc7d..e284a384fc6 100644 --- a/lang/en/course.php +++ b/lang/en/course.php @@ -73,6 +73,7 @@ $string['coursecontentnotifnewbody'] = '

{$a->moduletypename} link}">{$a->modulename} has been changed in the course {$a->coursename}.

Change your notification preferences

'; $string['coursecontentnotification_help'] = 'Tick the box to notify course participants about this new or changed activity or resource. Only users who can access the activity or resource will receive the notification.'; +$string['coursecount'] = 'Course count'; $string['coursenotyetstarted'] = 'The course has not yet started'; $string['coursenotyetfinished'] = 'The course has not yet finished'; $string['courseparticipants'] = 'Course participants';