From bbf95413fbdd9825039d97074c78e848b9b8de30 Mon Sep 17 00:00:00 2001 From: Paul Holden Date: Tue, 20 Jul 2021 17:38:52 +0100 Subject: [PATCH] MDL-72172 cohort: implement cohort datasource for custom reporting. Create two entities exposing reportable data on site cohorts and their members, via column and filter definitions. Create report source bringing them together along with the user entity to provide data for the reportbuilder editor. Co-authored-by: Carlos Castillo --- cohort/classes/local/entities/cohort.php | 280 ++++++++++++++++++ .../classes/local/entities/cohort_member.php | 120 ++++++++ .../reportbuilder/datasource/cohorts.php | 120 ++++++++ cohort/tests/behat/reportbuilder.feature | 100 +++++++ cohort/tests/datasource_test.php | 74 +++++ lang/en/cohort.php | 1 + 6 files changed, 695 insertions(+) create mode 100644 cohort/classes/local/entities/cohort.php create mode 100644 cohort/classes/local/entities/cohort_member.php create mode 100644 cohort/classes/reportbuilder/datasource/cohorts.php create mode 100644 cohort/tests/behat/reportbuilder.feature create mode 100644 cohort/tests/datasource_test.php diff --git a/cohort/classes/local/entities/cohort.php b/cohort/classes/local/entities/cohort.php new file mode 100644 index 00000000000..992a9761eb2 --- /dev/null +++ b/cohort/classes/local/entities/cohort.php @@ -0,0 +1,280 @@ +. + +declare(strict_types=1); + +namespace core_cohort\local\entities; + +use context; +use context_helper; +use lang_string; +use stdClass; +use core_reportbuilder\local\entities\base; +use core_reportbuilder\local\filters\date; +use core_reportbuilder\local\filters\select; +use core_reportbuilder\local\filters\text; +use core_reportbuilder\local\helpers\format; +use core_reportbuilder\local\report\column; +use core_reportbuilder\local\report\filter; + +/** + * Cohort entity + * + * @package core_cohort + * @copyright 2021 Paul Holden + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class cohort extends base { + + /** + * Database tables that this entity uses and their default aliases + * + * @return array + */ + protected function get_default_table_aliases(): array { + return ['cohort' => 'c']; + } + + /** + * The default title for this entity + * + * @return lang_string + */ + protected function get_default_entity_title(): lang_string { + return new lang_string('cohort', 'core_cohort'); + } + + /** + * Initialise the entity + * + * @return base + */ + public function initialise(): base { + $columns = $this->get_all_columns(); + foreach ($columns as $column) { + $this->add_column($column); + } + + // All the filters defined by the entity can also be used as conditions. + $filters = $this->get_all_filters(); + foreach ($filters as $filter) { + $this + ->add_filter($filter) + ->add_condition($filter); + } + + return $this; + } + + /** + * Returns list of all available columns + * + * @return column[] + */ + protected function get_all_columns(): array { + $tablealias = $this->get_table_alias('cohort'); + + // Category/context column. + $columns[] = (new column( + 'context', + new lang_string('category'), + $this->get_entity_name() + )) + ->add_joins($this->get_joins()) + ->set_type(column::TYPE_INTEGER) + ->add_fields("{$tablealias}.contextid") + ->set_is_sortable(true) + ->add_callback(static function(int $contextid): string { + return context::instance_by_id($contextid)->get_context_name(false); + }); + + // Name column. + $columns[] = (new column( + 'name', + new lang_string('name', 'core_cohort'), + $this->get_entity_name() + )) + ->add_joins($this->get_joins()) + ->set_type(column::TYPE_TEXT) + ->add_fields("{$tablealias}.name") + ->set_is_sortable(true); + + // ID number column. + $columns[] = (new column( + 'idnumber', + new lang_string('idnumber', 'core_cohort'), + $this->get_entity_name() + )) + ->add_joins($this->get_joins()) + ->set_type(column::TYPE_TEXT) + ->add_fields("{$tablealias}.idnumber") + ->set_is_sortable(true); + + // Description column. + $columns[] = (new column( + 'description', + new lang_string('description'), + $this->get_entity_name() + )) + ->add_joins($this->get_joins()) + ->set_type(column::TYPE_TEXT) + ->add_fields("{$tablealias}.description, {$tablealias}.descriptionformat, {$tablealias}.id, {$tablealias}.contextid") + ->add_callback(static function(string $description, stdClass $cohort): string { + global $CFG; + require_once("{$CFG->libdir}/filelib.php"); + + $description = file_rewrite_pluginfile_urls($description, 'pluginfile.php', $cohort->contextid, 'cohort', + 'description', $cohort->id); + + return format_text($description, $cohort->descriptionformat, ['context' => $cohort->contextid]); + }) + ->set_is_sortable(false); + + // Visible column. + $columns[] = (new column( + 'visible', + new lang_string('visible', 'core_cohort'), + $this->get_entity_name() + )) + ->add_joins($this->get_joins()) + ->set_type(column::TYPE_BOOLEAN) + ->add_fields("{$tablealias}.visible") + ->set_is_sortable(true) + ->set_callback([format::class, 'boolean_as_text']); + + // Time created column. + $columns[] = (new column( + 'timecreated', + new lang_string('timecreated', 'core_reportbuilder'), + $this->get_entity_name() + )) + ->add_joins($this->get_joins()) + ->set_type(column::TYPE_TIMESTAMP) + ->add_fields("{$tablealias}.timecreated") + ->set_is_sortable(true) + ->set_callback([format::class, 'userdate']); + + // Time modified column. + $columns[] = (new column( + 'timemodified', + new lang_string('timemodified', 'core_reportbuilder'), + $this->get_entity_name() + )) + ->add_joins($this->get_joins()) + ->set_type(column::TYPE_TIMESTAMP) + ->add_fields("{$tablealias}.timemodified") + ->set_is_sortable(true) + ->set_callback([format::class, 'userdate']); + + // Component column. + $columns[] = (new column( + 'component', + new lang_string('component', 'core_cohort'), + $this->get_entity_name() + )) + ->add_joins($this->get_joins()) + ->set_type(column::TYPE_TEXT) + ->add_fields("{$tablealias}.component") + ->set_is_sortable(true) + ->add_callback(static function(string $component): string { + return empty($component) + ? get_string('nocomponent', 'cohort') + : get_string('pluginname', $component); + }); + + // Theme column. + $columns[] = (new column( + 'theme', + new lang_string('theme'), + $this->get_entity_name() + )) + ->add_joins($this->get_joins()) + ->set_type(column::TYPE_TEXT) + ->add_fields("{$tablealias}.theme") + ->set_is_sortable(true); + + return $columns; + } + + /** + * Return list of all available filters + * + * @return filter[] + */ + protected function get_all_filters(): array { + $tablealias = $this->get_table_alias('cohort'); + + // Context filter. + $filters[] = (new filter( + select::class, + 'context', + new lang_string('category'), + $this->get_entity_name(), + "{$tablealias}.contextid" + )) + ->add_joins($this->get_joins()) + ->set_options_callback(static function(): array { + global $DB; + + // Load all contexts in which there are cohorts. + $ctxfields = context_helper::get_preload_record_columns_sql('ctx'); + $contexts = $DB->get_records_sql(" + SELECT DISTINCT {$ctxfields}, c.contextid + FROM {context} ctx + JOIN {cohort} c ON c.contextid = ctx.id"); + + // Transform context record into it's name (used as the filter options). + return array_map(static function(stdClass $contextrecord): string { + context_helper::preload_from_record($contextrecord); + + return context::instance_by_id($contextrecord->contextid) + ->get_context_name(false); + }, $contexts); + }); + + // Name filter. + $filters[] = (new filter( + text::class, + 'name', + new lang_string('name', 'core_cohort'), + $this->get_entity_name(), + "{$tablealias}.name" + )) + ->add_joins($this->get_joins()); + + // ID number filter. + $filters[] = (new filter( + text::class, + 'idnumber', + new lang_string('idnumber', 'core_cohort'), + $this->get_entity_name(), + "{$tablealias}.idnumber" + )) + ->add_joins($this->get_joins()); + + // Time created filter. + $filters[] = (new filter( + date::class, + 'timecreated', + new lang_string('timecreated', 'core_reportbuilder'), + $this->get_entity_name(), + "{$tablealias}.timecreated" + )) + ->add_joins($this->get_joins()); + + return $filters; + } +} diff --git a/cohort/classes/local/entities/cohort_member.php b/cohort/classes/local/entities/cohort_member.php new file mode 100644 index 00000000000..b05a661714b --- /dev/null +++ b/cohort/classes/local/entities/cohort_member.php @@ -0,0 +1,120 @@ +. + +declare(strict_types=1); + +namespace core_cohort\local\entities; + +use lang_string; +use core_reportbuilder\local\entities\base; +use core_reportbuilder\local\filters\date; +use core_reportbuilder\local\helpers\format; +use core_reportbuilder\local\report\column; +use core_reportbuilder\local\report\filter; + +/** + * Cohort member entity + * + * @package core_cohort + * @copyright 2021 Paul Holden + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class cohort_member extends base { + + /** + * Database tables that this entity uses and their default aliases + * + * @return array + */ + protected function get_default_table_aliases(): array { + return ['cohort_members' => 'cm']; + } + + /** + * The default title for this entity + * + * @return lang_string + */ + protected function get_default_entity_title(): lang_string { + return new lang_string('cohortmember', 'core_cohort'); + } + + /** + * Initialise the entity + * + * @return base + */ + public function initialise(): base { + $columns = $this->get_all_columns(); + foreach ($columns as $column) { + $this->add_column($column); + } + + // All the filters defined by the entity can also be used as conditions. + $filters = $this->get_all_filters(); + foreach ($filters as $filter) { + $this + ->add_filter($filter) + ->add_condition($filter); + } + + return $this; + } + + /** + * Returns list of all available columns + * + * @return column[] + */ + protected function get_all_columns(): array { + $tablealias = $this->get_table_alias('cohort_members'); + + // Time added column. + $columns[] = (new column( + 'timeadded', + new lang_string('timeadded', 'core_reportbuilder'), + $this->get_entity_name() + )) + ->add_joins($this->get_joins()) + ->set_type(column::TYPE_TIMESTAMP) + ->add_fields("{$tablealias}.timeadded") + ->set_is_sortable(true) + ->set_callback([format::class, 'userdate']); + + return $columns; + } + + /** + * Return list of all available filters + * + * @return filter[] + */ + protected function get_all_filters(): array { + $tablealias = $this->get_table_alias('cohort_members'); + + // Time added filter. + $filters[] = (new filter( + date::class, + 'timeadded', + new lang_string('timeadded', 'core_reportbuilder'), + $this->get_entity_name(), + "{$tablealias}.timeadded" + )) + ->add_joins($this->get_joins()); + + return $filters; + } +} diff --git a/cohort/classes/reportbuilder/datasource/cohorts.php b/cohort/classes/reportbuilder/datasource/cohorts.php new file mode 100644 index 00000000000..4f63e884a16 --- /dev/null +++ b/cohort/classes/reportbuilder/datasource/cohorts.php @@ -0,0 +1,120 @@ +. + +declare(strict_types=1); + +namespace core_cohort\reportbuilder\datasource; + +use core_cohort\local\entities\cohort; +use core_cohort\local\entities\cohort_member; +use core_reportbuilder\datasource; +use core_reportbuilder\local\entities\user; + +/** + * Cohorts datasource + * + * @package core_cohort + * @copyright 2021 Paul Holden + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class cohorts extends datasource { + + /** + * Return user friendly name of the datasource + * + * @return string + */ + public static function get_name(): string { + return get_string('cohorts', 'core_cohort'); + } + + /** + * Initialise report + */ + protected function initialise(): void { + $cohortentity = new cohort(); + $cohorttablealias = $cohortentity->get_table_alias('cohort'); + + $this->set_main_table('cohort', $cohorttablealias); + + $this->add_entity($cohortentity); + + // Join the cohort member entity to the cohort entity. + $cohortmemberentity = new cohort_member(); + $cohortmembertablealias = $cohortmemberentity->get_table_alias('cohort_members'); + + $cohortmemberjoin = "LEFT JOIN {cohort_members} {$cohortmembertablealias} + ON {$cohortmembertablealias}.cohortid = {$cohorttablealias}.id"; + + $this->add_entity($cohortmemberentity->add_join($cohortmemberjoin)); + + // Join the user entity to the cohort member entity. + $userentity = new user(); + $usertablealias = $userentity->get_table_alias('user'); + + $userjoin = "LEFT JOIN {user} {$usertablealias} + ON {$usertablealias}.id = {$cohortmembertablealias}.userid"; + + $this->add_entity($userentity->add_joins([$cohortmemberjoin, $userjoin])); + + // Add all columns from entities to be available in custom reports. + $this->add_columns_from_entity($cohortentity->get_entity_name()); + $this->add_columns_from_entity($cohortmemberentity->get_entity_name()); + $this->add_columns_from_entity($userentity->get_entity_name()); + + // Add all filters from entities to be available in custom reports. + $this->add_filters_from_entity($cohortentity->get_entity_name()); + $this->add_filters_from_entity($cohortmemberentity->get_entity_name()); + $this->add_filters_from_entity($userentity->get_entity_name()); + + // Add all conditions from entities to be available in custom reports. + $this->add_conditions_from_entity($cohortentity->get_entity_name()); + $this->add_conditions_from_entity($cohortmemberentity->get_entity_name()); + $this->add_conditions_from_entity($userentity->get_entity_name()); + } + + /** + * Return the columns that will be added to the report as part of default setup + * + * @return string[] + */ + public function get_default_columns(): array { + return [ + 'cohort:context', + 'cohort:name', + 'cohort:idnumber', + 'cohort:description', + ]; + } + + /** + * Return the filters that will be added to the report once is created + * + * @return string[] + */ + public function get_default_filters(): array { + return ['cohort:context', 'cohort:name']; + } + + /** + * Return the conditions that will be added to the report once is created + * + * @return string[] + */ + public function get_default_conditions(): array { + return []; + } +} diff --git a/cohort/tests/behat/reportbuilder.feature b/cohort/tests/behat/reportbuilder.feature new file mode 100644 index 00000000000..cc693ffb364 --- /dev/null +++ b/cohort/tests/behat/reportbuilder.feature @@ -0,0 +1,100 @@ +@core_reportbuilder @javascript +Feature: Manage custom reports for cohorts + In order to manage custom reports for cohorts + As an admin and user + I need to create new, view and edit existing reports + + Background: + Given the following "cohorts" exist: + | name | idnumber | contextid | + | Another one | AO | 1 | + | MDL-62161 | 62161 | 1 | + | New system cohort | NSC | 1 | + | MDL-62162 | 62162 | 1 | + | Other cohort | LC | 3 | + And the following "users" exist: + | username | firstname | lastname | email | + | user1 | Alice | Last1 | user1@example.com | + | user2 | Carlos | Last2 | user2@example.com | + | user3 | Paul | Last3 | user3@example.com | + | user4 | Juan | Last4 | user4@example.com | + | user5 | Pedro | Last5 | user5@example.com | + | user6 | Luis | Last6 | user6@example.com | + | user7 | David | Last7 | user7@example.com | + | user8 | Zoe | Last8 | user8@example.com | + And the following "cohort members" exist: + | user | cohort | + | user1 | AO | + | user2 | AO | + | user3 | AO | + | user4 | AO | + | user5 | 62161 | + | user6 | 62161 | + | user7 | NSC | + | user8 | NSC | + And the following "core_reportbuilder > Reports" exist: + | name | source | default | + | My report | core_cohort\reportbuilder\datasource\cohorts | 0 | + And the following "core_reportbuilder > Columns" exist: + | report | uniqueidentifier | + | My report | cohort:context | + | My report | cohort:name | + + Scenario: Add condition to cohorts report + Given I am on the "My report" "reportbuilder > Editor" page logged in as "admin" + And I change window size to "large" + When I click on "Show/hide settings sidebar" "button" + And I click on "Show/hide 'Conditions'" "button" + Then I should see "There are no conditions selected" in the "[data-region='settings-conditions']" "css_element" + And I set the field "Select a condition" to "Category" + And I should see "Added condition 'Category'" + And I should not see "There are no conditions selected" in the "[data-region='settings-conditions']" "css_element" + And I set the following fields in the "Category" "core_reportbuilder > Condition" to these values: + | Category operator | Is equal to | + | Category value | 3 | + And I click on "Apply" "button" in the "[data-region='settings-conditions']" "css_element" + And I should see "Conditions applied" + And I should see "Other cohort" in the "reportbuilder-table" "table" + And I should not see "MDL-62162" in the "reportbuilder-table" "table" + + Scenario: Use filters in cohorts report + Given I am on the "My report" "reportbuilder > Editor" page logged in as "admin" + And I change window size to "large" + When I click on "Show/hide settings sidebar" "button" + And I click on "Show/hide 'Filters'" "button" + Then I should see "There are no filters selected" in the "[data-region='settings-filters']" "css_element" + And I set the field "Select a filter" to "Name" + And I should see "Other cohort" in the ".reportbuilder-table" "css_element" + And I should see "MDL-62162" in the ".reportbuilder-table" "css_element" + When I click on "Switch to preview mode" "button" + And I click on "Filters" "button" in the "[data-region='core_reportbuilder/report-header']" "css_element" + And I set the following fields in the "Name" "core_reportbuilder > Filter" to these values: + | Name operator | Contains | + | Name value | Another | + And I click on "Apply" "button" in the "[data-region='core_reportbuilder/report-header']" "css_element" + Then the following should exist in the "reportbuilder-table" table: + | Category | Name | + | System | Another one | + And the following should not exist in the "reportbuilder-table" table: + | Category | Name | + | Miscellaneous | Other cohort | + + Scenario: Use sorting and aggregations in cohorts report + Given the following "core_reportbuilder > Columns" exist: + | report | uniqueidentifier | + | My report | user:lastname | + And I am on the "My report" "reportbuilder > Editor" page logged in as "admin" + And I set the field "Rename column 'Surname'" to "Members" + And I reload the page + And I set the field "Aggregate column 'Surname'" to "Comma separated distinct values" + And I click on "Show/hide settings sidebar" "button" + And I click on "Show/hide 'Sorting'" "button" + And I change window size to "large" + And I click on "Move sorting for column 'Surname'" "button" + And I click on "To the top of the list" "link" in the "Move sorting for column 'Surname'" "dialogue" + And I click on "Enable sorting for column 'Surname'" "checkbox" + And "Another one" "table_row" should appear before "MDL-62161" "table_row" + When I click on "Sort column 'Surname' descending" "button" + Then I should see "Updated sorting for column 'Surname'" + And I wait "1" seconds + And "MDL-62161" "table_row" should appear before "Another one" "table_row" diff --git a/cohort/tests/datasource_test.php b/cohort/tests/datasource_test.php new file mode 100644 index 00000000000..eb6533d533c --- /dev/null +++ b/cohort/tests/datasource_test.php @@ -0,0 +1,74 @@ +. + +declare(strict_types=1); + +namespace core_cohort\reportbuilder\datasource; + +use core_reportbuilder_testcase; +use core_reportbuilder_generator; + +defined('MOODLE_INTERNAL') || die(); + +global $CFG; +require_once("{$CFG->dirroot}/reportbuilder/tests/helpers.php"); + +/** + * Unit tests for cohorts datasource + * + * @package core_cohort + * @covers \core_cohort\reportbuilder\datasource\cohorts + * @copyright 2021 Paul Holden + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class datasource_test extends core_reportbuilder_testcase { + + /** + * Test cohorts datasource + */ + public function test_cohorts_datasource(): void { + $this->resetAfterTest(); + + // Test subject. + $cohort = $this->getDataGenerator()->create_cohort([ + 'name' => 'Legends', + 'idnumber' => 'C101', + 'description' => 'Cohort for the legends', + ]); + + $user = $this->getDataGenerator()->create_user(['firstname' => 'Lionel', 'lastname' => 'Richards']); + cohort_add_member($cohort->id, $user->id); + + /** @var core_reportbuilder_generator $generator */ + $generator = $this->getDataGenerator()->get_plugin_generator('core_reportbuilder'); + $report = $generator->create_report(['name' => 'Cohorts', 'source' => cohorts::class]); + + // Add user fullname column to the report. + $generator->create_column(['reportid' => $report->get('id'), 'uniqueidentifier' => 'user:fullname']); + + $content = $this->get_custom_report_content($report->get('id')); + $this->assertCount(1, $content); + + $contentrow = array_values(reset($content)); + $this->assertEquals([ + 'System', // Context. + 'Legends', // Name. + 'C101', // ID number. + '
Cohort for the legends
', // Description. + 'Lionel Richards', // User. + ], $contentrow); + } +} diff --git a/lang/en/cohort.php b/lang/en/cohort.php index 9badc3818aa..9396d8c8aa6 100644 --- a/lang/en/cohort.php +++ b/lang/en/cohort.php @@ -33,6 +33,7 @@ $string['bulkadd'] = 'Add to cohort'; $string['bulknocohort'] = 'No available cohorts found'; $string['categorynotfound'] = 'Category {$a} not found or you don\'t have permission to create a cohort there. The default context will be used.'; $string['cohort'] = 'Cohort'; +$string['cohortmember'] = 'Cohort member'; $string['cohorts'] = 'Cohorts'; $string['cohortsin'] = '{$a}: available cohorts'; $string['assigncohorts'] = 'Assign cohort members';