From 0a66ba01888e56bccb079b77c130efb3505913d5 Mon Sep 17 00:00:00 2001 From: David Matamoros Date: Wed, 30 Mar 2022 12:34:04 +0200 Subject: [PATCH] MDL-74364 course: Add course participants datasource - New Report Builder source for course participants - New course completion entity - New course enrolment entity - New course last access entity --- .../reportbuilder/datasource/participants.php | 148 +++++++++ .../reportbuilder/local/entities/access.php | 138 ++++++++ .../local/entities/completion.php | 280 ++++++++++++++++ .../local/entities/enrolment.php | 302 ++++++++++++++++++ .../local/formatters/completion.php | 77 +++++ .../local/formatters/enrolment.php | 81 +++++ .../datasource/participants_test.php | 280 ++++++++++++++++ lang/en/completion.php | 2 + lang/en/course.php | 3 + lang/en/enrol.php | 6 + reportbuilder/tests/helpers.php | 7 +- reportbuilder/upgrade.txt | 1 + 12 files changed, 1324 insertions(+), 1 deletion(-) create mode 100644 course/classes/reportbuilder/datasource/participants.php create mode 100644 course/classes/reportbuilder/local/entities/access.php create mode 100644 course/classes/reportbuilder/local/entities/completion.php create mode 100644 course/classes/reportbuilder/local/entities/enrolment.php create mode 100644 course/classes/reportbuilder/local/formatters/completion.php create mode 100644 course/classes/reportbuilder/local/formatters/enrolment.php create mode 100644 course/tests/reportbuilder/datasource/participants_test.php diff --git a/course/classes/reportbuilder/datasource/participants.php b/course/classes/reportbuilder/datasource/participants.php new file mode 100644 index 00000000000..c53acf12407 --- /dev/null +++ b/course/classes/reportbuilder/datasource/participants.php @@ -0,0 +1,148 @@ +. + +declare(strict_types=1); + +namespace core_course\reportbuilder\datasource; + +use core_course\local\entities\course_category; +use core_course\reportbuilder\local\entities\access; +use core_course\reportbuilder\local\entities\completion; +use core_course\reportbuilder\local\entities\enrolment; +use core_reportbuilder\datasource; +use core_reportbuilder\local\entities\course; +use core_reportbuilder\local\entities\user; +use core_reportbuilder\local\helpers\database; + +/** + * Course participants datasource + * + * @package core_course + * @copyright 2022 David Matamoros + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class participants extends datasource { + + /** + * Initialise report + */ + protected function initialise(): void { + $courseentity = new course(); + $course = $courseentity->get_table_alias('course'); + $this->add_entity($courseentity); + + $this->set_main_table('course', $course); + + // Exclude site course. + $paramsiteid = database::generate_param_name(); + $this->add_base_condition_sql("{$course}.id != :{$paramsiteid}", [$paramsiteid => SITEID]); + + // Join the course category entity. + $coursecatentity = new course_category(); + $categories = $coursecatentity->get_table_alias('course_categories'); + $this->add_entity($coursecatentity + ->add_join("JOIN {course_categories} {$categories} ON {$categories}.id = {$course}.category")); + + // Join the enrolments entity. + $enrolmententity = new enrolment(); + $userenrolment = $enrolmententity->get_table_alias('user_enrolments'); + $enrol = $enrolmententity->get_table_alias('enrol'); + $enroljoin = "LEFT JOIN {enrol} {$enrol} ON {$enrol}.courseid = {$course}.id"; + $userenrolmentjoin = " LEFT JOIN {user_enrolments} {$userenrolment} ON {$userenrolment}.enrolid = {$enrol}.id"; + $enrolmententity->add_joins([$enroljoin, $userenrolmentjoin]); + $this->add_entity($enrolmententity); + + // Join user entity. + $userentity = new user(); + $user = $userentity->get_table_alias('user'); + $userentity->add_joins($enrolmententity->get_joins()); + $userentity->add_join("LEFT JOIN {user} {$user} ON {$userenrolment}.userid = {$user}.id"); + $this->add_entity($userentity); + + // Join completion entity. + $completionentity = new completion(); + $completion = $completionentity->get_table_alias('course_completion'); + $completionentity->add_joins($userentity->get_joins()); + $completionentity->add_join(" + LEFT JOIN {course_completions} {$completion} + ON {$completion}.course = {$course}.id AND {$completion}.userid = {$user}.id + "); + $completionentity->set_table_alias('user', $user); + $this->add_entity($completionentity); + + // Join course access entity. + $accessentity = new access(); + $lastaccess = $accessentity->get_table_alias('user_lastaccess'); + $accessentity->add_joins($userentity->get_joins()); + $accessentity->add_join(" + LEFT JOIN {user_lastaccess} {$lastaccess} + ON {$lastaccess}.userid = {$user}.id AND {$lastaccess}.courseid = {$course}.id + "); + $this->add_entity($accessentity); + + // Do not show deleted users. + $this->add_base_condition_sql("{$user}.deleted = 0"); + + // Add all entities columns/filters/conditions. + $this->add_all_from_entities(); + } + + /** + * Return user friendly name of the datasource + * + * @return string + */ + public static function get_name(): string { + return get_string('courseparticipants', 'course'); + } + + /** + * 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:coursefullnamewithlink', + 'enrolment:method', + 'user:fullnamewithlink', + ]; + } + + /** + * Return the filters that will be added to the report once is created + * + * @return string[] + */ + public function get_default_filters(): array { + return [ + 'user:suspended', + 'user:confirmed', + ]; + } + + /** + * Return the conditions that will be added to the report once is created + * + * @return string[] + */ + public function get_default_conditions(): array { + return [ + 'user:suspended', + 'user:confirmed', + ]; + } +} diff --git a/course/classes/reportbuilder/local/entities/access.php b/course/classes/reportbuilder/local/entities/access.php new file mode 100644 index 00000000000..3b38073bb56 --- /dev/null +++ b/course/classes/reportbuilder/local/entities/access.php @@ -0,0 +1,138 @@ +. + +declare(strict_types=1); + +namespace core_course\reportbuilder\local\entities; + +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; +use lang_string; +use stdClass; + +/** + * Course access entity implementation + * + * @package core_course + * @copyright 2022 David Matamoros + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class access extends base { + + /** + * Database tables that this entity uses and their default aliases + * + * @return array + */ + protected function get_default_table_aliases(): array { + return ['user_lastaccess' => 'ula', 'user' => 'u']; + } + + /** + * The default title for this entity in the list of columns/conditions/filters in the report builder + * + * @return lang_string + */ + protected function get_default_entity_title(): lang_string { + return new lang_string('courseaccess', 'course'); + } + + /** + * Initialise the entity + * + * @return base + */ + public function initialise(): base { + foreach ($this->get_all_columns() as $column) { + $this->add_column($column); + } + + // All the filters defined by the entity can also be used as conditions. + foreach ($this->get_all_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('user_lastaccess'); + $user = $this->get_table_alias('user'); + + // Last course access column. + $columns[] = (new column( + 'timeaccess', + new lang_string('lastcourseaccess', 'moodle'), + $this->get_entity_name() + )) + ->add_joins($this->get_joins()) + ->set_type(column::TYPE_TIMESTAMP) + ->add_field("{$tablealias}.timeaccess") + ->add_field("{$user}.id", 'userid') + ->set_is_sortable(true) + ->add_callback([format::class, 'userdate']) + ->add_callback(static function(string $value, stdClass $row): string { + if (!$row->userid) { + return ''; + } + if ($value === '') { + return get_string('never'); + } + return $value; + }); + + return $columns; + } + + /** + * Return list of all available filters + * + * @return filter[] + */ + protected function get_all_filters(): array { + $tablealias = $this->get_table_alias('user_lastaccess'); + + // Last course access filter. + $filters[] = (new filter( + date::class, + 'timeaccess', + new lang_string('lastcourseaccess', 'moodle'), + $this->get_entity_name(), + "{$tablealias}.timeaccess" + )) + ->add_joins($this->get_joins()) + ->set_limited_operators([ + date::DATE_ANY, + date::DATE_NOT_EMPTY, + date::DATE_EMPTY, + date::DATE_RANGE, + date::DATE_LAST, + date::DATE_CURRENT, + ]); + + return $filters; + } +} diff --git a/course/classes/reportbuilder/local/entities/completion.php b/course/classes/reportbuilder/local/entities/completion.php new file mode 100644 index 00000000000..efcad9248bb --- /dev/null +++ b/course/classes/reportbuilder/local/entities/completion.php @@ -0,0 +1,280 @@ +. + +declare(strict_types=1); + +namespace core_course\reportbuilder\local\entities; + +use core_reportbuilder\local\entities\base; +use core_course\reportbuilder\local\formatters\completion as completion_formatter; +use core_reportbuilder\local\filters\boolean_select; +use core_reportbuilder\local\filters\date; +use core_reportbuilder\local\helpers\format; +use core_reportbuilder\local\report\column; +use core_reportbuilder\local\report\filter; +use lang_string; +use stdClass; + +/** + * Course completion entity implementation + * + * @package core_course + * @copyright 2022 David Matamoros + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class completion extends base { + + /** + * Database tables that this entity uses and their default aliases + * + * @return array + */ + protected function get_default_table_aliases(): array { + return [ + 'course_completion' => 'ccomp', + 'course' => 'c', + 'grade_grades' => 'gg', + 'grade_items' => 'gi', + 'user' => 'u', + ]; + } + + /** + * The default title for this entity in the list of columns/conditions/filters in the report builder + * + * @return lang_string + */ + protected function get_default_entity_title(): lang_string { + return new lang_string('coursecompletion', 'completion'); + } + + /** + * Initialise the entity + * + * @return base + */ + public function initialise(): base { + foreach ($this->get_all_columns() as $column) { + $this->add_column($column); + } + + // All the filters defined by the entity can also be used as conditions. + foreach ($this->get_all_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 { + $coursecompletion = $this->get_table_alias('course_completion'); + $course = $this->get_table_alias('course'); + $grade = $this->get_table_alias('grade_grades'); + $gradeitem = $this->get_table_alias('grade_items'); + $user = $this->get_table_alias('user'); + + // Completed column. + $columns[] = (new column( + 'completed', + new lang_string('completed', 'completion'), + $this->get_entity_name() + )) + ->add_joins($this->get_joins()) + ->set_type(column::TYPE_BOOLEAN) + ->add_field("CASE WHEN {$coursecompletion}.timecompleted > 0 THEN 1 ELSE 0 END", 'completed') + ->add_field("{$user}.id", 'userid') + ->set_is_sortable(true) + ->add_callback(static function(bool $value, stdClass $row): string { + if (!$row->userid) { + return ''; + } + return format::boolean_as_text($value); + }); + + // Progress percentage column. + $columns[] = (new column( + 'progresspercent', + new lang_string('progress', 'completion'), + $this->get_entity_name() + )) + ->add_joins($this->get_joins()) + ->set_type(column::TYPE_TEXT) + ->add_field("{$course}.id", 'courseid') + ->add_field("{$user}.id", 'userid') + ->set_is_sortable(false) + ->add_callback([completion_formatter::class, 'completion_progress']); + + // Time enrolled. + $columns[] = (new column( + 'timeenrolled', + new lang_string('timeenrolled', 'enrol'), + $this->get_entity_name() + )) + ->add_joins($this->get_joins()) + ->set_type(column::TYPE_TIMESTAMP) + ->add_field("{$coursecompletion}.timeenrolled") + ->set_is_sortable(true) + ->add_callback([format::class, 'userdate']); + + // Time started. + $columns[] = (new column( + 'timestarted', + new lang_string('timestarted', 'enrol'), + $this->get_entity_name() + )) + ->add_joins($this->get_joins()) + ->set_type(column::TYPE_TIMESTAMP) + ->add_field("{$coursecompletion}.timestarted") + ->set_is_sortable(true) + ->add_callback([format::class, 'userdate']); + + // Time completed. + $columns[] = (new column( + 'timecompleted', + new lang_string('timecompleted', 'completion'), + $this->get_entity_name() + )) + ->add_joins($this->get_joins()) + ->set_type(column::TYPE_TIMESTAMP) + ->add_field("{$coursecompletion}.timecompleted") + ->set_is_sortable(true) + ->add_callback([format::class, 'userdate']); + + // Time reaggregated. + $columns[] = (new column( + 'reaggregate', + new lang_string('timereaggregated', 'enrol'), + $this->get_entity_name() + )) + ->add_joins($this->get_joins()) + ->set_type(column::TYPE_TIMESTAMP) + ->add_field("{$coursecompletion}.reaggregate") + ->set_is_sortable(true) + ->add_callback([format::class, 'userdate']); + + // Days taking course (days since course start date until completion or until current date if not completed). + $currenttime = time(); + $columns[] = (new column( + 'dayscourse', + new lang_string('daystakingcourse', 'course'), + $this->get_entity_name() + )) + ->add_joins($this->get_joins()) + ->set_type(column::TYPE_INTEGER) + ->add_field("( + CASE + WHEN {$coursecompletion}.timecompleted > 0 THEN + {$coursecompletion}.timecompleted + ELSE + {$currenttime} + END - {$course}.startdate) / " . DAYSECS, 'dayscourse') + ->add_field("{$user}.id", 'userid') + ->set_is_sortable(true) + ->add_callback([completion_formatter::class, 'get_days']); + + // Days since last completion (days since last enrolment date until completion or until current date if not completed). + $columns[] = (new column( + 'daysuntilcompletion', + new lang_string('daysuntilcompletion', 'completion'), + $this->get_entity_name() + )) + ->add_joins($this->get_joins()) + ->set_type(column::TYPE_INTEGER) + ->add_field("( + CASE + WHEN {$coursecompletion}.timecompleted > 0 THEN + {$coursecompletion}.timecompleted + ELSE + {$currenttime} + END - {$coursecompletion}.timeenrolled) / " . DAYSECS, 'daysuntilcompletion') + ->add_field("{$user}.id", 'userid') + ->set_is_sortable(true) + ->add_callback([completion_formatter::class, 'get_days']); + + // Student course grade. + $columns[] = (new column( + 'grade', + new lang_string('gradenoun'), + $this->get_entity_name() + )) + ->add_joins($this->get_joins()) + ->add_join(" + LEFT JOIN {grade_items} {$gradeitem} + ON ({$gradeitem}.itemtype = 'course' AND {$course}.id = {$gradeitem}.courseid) + ") + ->add_join(" + LEFT JOIN {grade_grades} {$grade} + ON ({$user}.id = {$grade}.userid AND {$gradeitem}.id = {$grade}.itemid) + ") + ->set_type(column::TYPE_INTEGER) + ->add_fields("{$grade}.finalgrade") + ->set_is_sortable(true) + ->add_callback(function ($value) { + if (!$value) { + return ''; + } + return format_float($value, 2); + }); + + return $columns; + } + + /** + * Return list of all available filters + * + * @return filter[] + */ + protected function get_all_filters(): array { + $coursecompletion = $this->get_table_alias('course_completion'); + + // Completed status filter. + $filters[] = (new filter( + boolean_select::class, + 'completed', + new lang_string('completed', 'completion'), + $this->get_entity_name(), + "CASE WHEN {$coursecompletion}.timecompleted > 0 THEN 1 ELSE 0 END" + )) + ->add_joins($this->get_joins()); + + // Time completed filter. + $filters[] = (new filter( + date::class, + 'timecompleted', + new lang_string('timecompleted', 'completion'), + $this->get_entity_name(), + "{$coursecompletion}.timecompleted" + )) + ->add_joins($this->get_joins()) + ->set_limited_operators([ + date::DATE_ANY, + date::DATE_NOT_EMPTY, + date::DATE_EMPTY, + date::DATE_RANGE, + date::DATE_LAST, + date::DATE_CURRENT, + ]); + + return $filters; + } +} diff --git a/course/classes/reportbuilder/local/entities/enrolment.php b/course/classes/reportbuilder/local/entities/enrolment.php new file mode 100644 index 00000000000..c55e3141770 --- /dev/null +++ b/course/classes/reportbuilder/local/entities/enrolment.php @@ -0,0 +1,302 @@ +. + +declare(strict_types=1); + +namespace core_course\reportbuilder\local\entities; + +use context_course; +use core_course\reportbuilder\local\formatters\enrolment as enrolment_formatter; +use core_reportbuilder\local\entities\base; +use core_reportbuilder\local\filters\date; +use core_reportbuilder\local\filters\select; +use core_reportbuilder\local\helpers\database; +use core_reportbuilder\local\helpers\format; +use core_reportbuilder\local\report\column; +use core_reportbuilder\local\report\filter; +use core_user\output\status_field; +use enrol_plugin; +use lang_string; +use stdClass; + +/** + * Course enrolment entity implementation + * + * @package core_course + * @copyright 2022 David Matamoros + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class enrolment extends base { + + /** + * Database tables that this entity uses and their default aliases + * + * @return array + */ + protected function get_default_table_aliases(): array { + return ['user_enrolments' => 'ue', 'enrol' => 'e']; + } + + /** + * The default title for this entity in the list of columns/conditions/filters in the report builder + * + * @return lang_string + */ + protected function get_default_entity_title(): lang_string { + return new lang_string('enrolment', 'enrol'); + } + + /** + * Initialise the entity + * + * @return base + */ + public function initialise(): base { + foreach ($this->get_all_columns() as $column) { + $this->add_column($column); + } + + // All the filters defined by the entity can also be used as conditions. + foreach ($this->get_all_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 { + $userenrolments = $this->get_table_alias('user_enrolments'); + $enrol = $this->get_table_alias('enrol'); + + // Enrolment method column. + $columns[] = (new column( + 'method', + new lang_string('method', 'enrol'), + $this->get_entity_name() + )) + ->add_joins($this->get_joins()) + ->set_type(column::TYPE_TEXT) + ->add_fields("{$enrol}.enrol, {$enrol}.id") + ->set_is_sortable(true) + ->add_callback([enrolment_formatter::class, 'enrolment_name']); + + // Enrolment time created. + $columns[] = (new column( + 'timecreated', + new lang_string('timecreated', 'moodle'), + $this->get_entity_name() + )) + ->add_joins($this->get_joins()) + ->set_type(column::TYPE_TIMESTAMP) + ->add_field("{$userenrolments}.timecreated") + ->set_is_sortable(true) + ->add_callback([format::class, 'userdate']); + + // Enrolment time started. + $columns[] = (new column( + 'timestarted', + new lang_string('timestarted', 'enrol'), + $this->get_entity_name() + )) + ->add_joins($this->get_joins()) + ->set_type(column::TYPE_TIMESTAMP) + ->add_field(" + CASE WHEN {$userenrolments}.timestart = 0 + THEN {$userenrolments}.timecreated + ELSE {$userenrolments}.timestart + END", 'timestarted') + ->set_is_sortable(true) + ->add_callback([format::class, 'userdate']); + + // Enrolment time ended. + $columns[] = (new column( + 'timeended', + new lang_string('timeended', 'enrol'), + $this->get_entity_name() + )) + ->add_joins($this->get_joins()) + ->set_type(column::TYPE_TIMESTAMP) + ->add_field("{$userenrolments}.timeend") + ->set_is_sortable(true) + ->add_callback([format::class, 'userdate']); + + // Enrolment status. + $columns[] = (new column( + 'status', + new lang_string('status', 'moodle'), + $this->get_entity_name() + )) + ->add_joins($this->get_joins()) + ->set_type(column::TYPE_TEXT) + ->add_field($this->get_status_field_sql(), 'status') + ->add_field("{$userenrolments}.userid") + ->set_is_sortable(true) + ->add_callback([enrolment_formatter::class, 'enrolment_status']); + + // Role method column. + $ctx = database::generate_alias(); + $ra = database::generate_alias(); + $r = database::generate_alias(); + $columns[] = (new column( + 'role', + new lang_string('role', 'moodle'), + $this->get_entity_name() + )) + ->add_joins($this->get_joins()) + ->add_join("LEFT JOIN {context} {$ctx} + ON {$ctx}.instanceid = {$enrol}.courseid AND {$ctx}.contextlevel = " . CONTEXT_COURSE) + ->add_join("LEFT JOIN {role_assignments} {$ra} + ON {$ra}.contextid = {$ctx}.id AND {$ra}.userid = {$userenrolments}.userid") + ->add_join("LEFT JOIN {role} {$r} ON {$r}.id = {$ra}.roleid") + ->set_type(column::TYPE_TEXT) + ->add_fields("{$r}.id, {$r}.name, {$r}.shortname, {$ctx}.instanceid") + ->set_is_sortable(true, ["{$r}.shortname"]) + ->add_callback(static function(?string $value, stdClass $row): string { + if (!$row->id) { + return ''; + } + $context = context_course::instance($row->instanceid); + return role_get_name($row, $context, ROLENAME_ALIAS); + }); + + return $columns; + } + + /** + * Generate SQL snippet suitable for returning enrolment status field + * + * @return string + */ + private function get_status_field_sql(): string { + $time = time(); + $userenrolments = $this->get_table_alias('user_enrolments'); + $enrol = $this->get_table_alias('enrol'); + + return " + CASE WHEN {$userenrolments}.status = " . ENROL_USER_ACTIVE . " + THEN CASE WHEN ({$userenrolments}.timestart > {$time}) + OR ({$userenrolments}.timeend > 0 AND {$userenrolments}.timeend < {$time}) + OR ({$enrol}.status = " . ENROL_INSTANCE_DISABLED . ") + THEN " . status_field::STATUS_NOT_CURRENT . " + ELSE " . status_field::STATUS_ACTIVE . " + END + ELSE " . status_field::STATUS_SUSPENDED . " + END"; + } + + /** + * Return list of all available filters + * + * @return filter[] + */ + protected function get_all_filters(): array { + $userenrolments = $this->get_table_alias('user_enrolments'); + $enrol = $this->get_table_alias('enrol'); + + // Enrolment method. + $enrolmentmethods = static function(): array { + return array_map(static function(enrol_plugin $plugin): string { + return get_string('pluginname', 'enrol_' . $plugin->get_name()); + }, enrol_get_plugins(true)); + }; + $filters[] = (new filter( + select::class, + 'method', + new lang_string('method', 'enrol'), + $this->get_entity_name(), + "{$enrol}.enrol" + )) + ->add_joins($this->get_joins()) + ->set_options_callback($enrolmentmethods); + + // Enrolment time created. + $filters[] = (new filter( + date::class, + 'timecreated', + new lang_string('timecreated', 'moodle'), + $this->get_entity_name(), + "{$userenrolments}.timecreated" + )) + ->add_joins($this->get_joins()) + ->set_limited_operators([ + date::DATE_ANY, + date::DATE_NOT_EMPTY, + date::DATE_EMPTY, + date::DATE_RANGE, + date::DATE_LAST, + date::DATE_CURRENT, + ]); + + // Enrolment time started. + $filters[] = (new filter( + date::class, + 'timestarted', + new lang_string('timestarted', 'enrol'), + $this->get_entity_name(), + "CASE WHEN {$userenrolments}.timestart = 0 + THEN {$userenrolments}.timecreated + ELSE {$userenrolments}.timestart + END" + )) + ->add_joins($this->get_joins()) + ->set_limited_operators([ + date::DATE_ANY, + date::DATE_NOT_EMPTY, + date::DATE_EMPTY, + date::DATE_RANGE, + date::DATE_LAST, + date::DATE_CURRENT, + ]); + + // Enrolment time ended. + $filters[] = (new filter( + date::class, + 'timeended', + new lang_string('timeended', 'enrol'), + $this->get_entity_name(), + "{$userenrolments}.timeend" + )) + ->add_joins($this->get_joins()) + ->set_limited_operators([ + date::DATE_ANY, + date::DATE_NOT_EMPTY, + date::DATE_EMPTY, + date::DATE_RANGE, + date::DATE_LAST, + date::DATE_CURRENT, + ]); + + // Enrolment status. + $filters[] = (new filter( + select::class, + 'status', + new lang_string('status', 'moodle'), + $this->get_entity_name(), + $this->get_status_field_sql() + )) + ->add_joins($this->get_joins()) + ->set_options(enrolment_formatter::enrolment_values()); + + return $filters; + } +} diff --git a/course/classes/reportbuilder/local/formatters/completion.php b/course/classes/reportbuilder/local/formatters/completion.php new file mode 100644 index 00000000000..ef8b7983340 --- /dev/null +++ b/course/classes/reportbuilder/local/formatters/completion.php @@ -0,0 +1,77 @@ +. + +declare(strict_types=1); + +namespace core_course\reportbuilder\local\formatters; + +use core_completion\progress; +use core_reportbuilder\local\helpers\format; +use stdClass; + +/** + * Formatters for the course completion entity + * + * @package core_course + * @copyright 2022 David Matamoros + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class completion { + + /** + * Return completion progress as a percentage + * + * @param string|null $value + * @param stdClass $row + * @return string + */ + public static function completion_progress(?string $value, stdClass $row): string { + global $CFG; + require_once($CFG->libdir . '/completionlib.php'); + + // Do not show progress if there is no userid. + if (!$row->userid) { + return ''; + } + + // Make sure courseid and userid have a value, specially userid because get_course_progress_percentage() defaults + // to the current user if this is null and the result would be wrong. + $courseid = (int) $row->courseid; + $userid = (int) $row->userid; + if ($courseid === 0 || $userid === 0) { + return format::percent(0); + } + + $course = get_course($courseid); + $progress = (float) progress::get_course_progress_percentage($course, $userid); + return format::percent($progress); + } + + /** + * Return number of days for methods daystakingcourse and daysuntilcompletion + * + * @param int $value + * @param stdClass $row + * @return int|null + */ + public static function get_days(int $value, stdClass $row): ?int { + // Do not show anything if there is no userid. + if (!$row->userid) { + return null; + } + return $value; + } +} diff --git a/course/classes/reportbuilder/local/formatters/enrolment.php b/course/classes/reportbuilder/local/formatters/enrolment.php new file mode 100644 index 00000000000..f4ec7867fc1 --- /dev/null +++ b/course/classes/reportbuilder/local/formatters/enrolment.php @@ -0,0 +1,81 @@ +. + +declare(strict_types=1); + +namespace core_course\reportbuilder\local\formatters; + +use core_user\output\status_field; +use lang_string; +use stdClass; + +/** + * Formatters for the course enrolment entity + * + * @package core_course + * @copyright 2022 David Matamoros + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class enrolment { + + /** + * Return enrolment plugin instance name + * + * @param string|null $value + * @param stdClass $row + * @return string + */ + public static function enrolment_name(?string $value, stdClass $row): string { + global $DB; + + if (empty($value)) { + return ''; + } + + $instance = $DB->get_record('enrol', ['id' => $row->id, 'enrol' => $row->enrol], '*', MUST_EXIST); + $plugin = enrol_get_plugin($row->enrol); + + return $plugin ? $plugin->get_instance_name($instance) : '-'; + } + + /** + * Returns list of enrolment statuses + * + * @return lang_string[] + */ + public static function enrolment_values(): array { + return [ + status_field::STATUS_ACTIVE => new lang_string('participationactive', 'enrol'), + status_field::STATUS_SUSPENDED => new lang_string('participationsuspended', 'enrol'), + status_field::STATUS_NOT_CURRENT => new lang_string('participationnotcurrent', 'enrol'), + ]; + } + + /** + * Return enrolment status for user + * + * @param string $value + * @param stdClass $row + * @return lang_string|null + */ + public static function enrolment_status(string $value, stdClass $row): ?lang_string { + if (!$row->userid) { + return null; + } + $statusvalues = self::enrolment_values(); + return $statusvalues[(int) $value] ?? null; + } +} diff --git a/course/tests/reportbuilder/datasource/participants_test.php b/course/tests/reportbuilder/datasource/participants_test.php new file mode 100644 index 00000000000..5bc5bc3e545 --- /dev/null +++ b/course/tests/reportbuilder/datasource/participants_test.php @@ -0,0 +1,280 @@ +. + +declare(strict_types=1); + +namespace core_course\reportbuilder\datasource; + +use completion_completion; +use core_reportbuilder\local\filters\boolean_select; +use core_reportbuilder\local\filters\date; +use core_reportbuilder\local\filters\select; +use core_reportbuilder_generator; +use core_reportbuilder_testcase; +use grade_item; + +defined('MOODLE_INTERNAL') || die(); + +global $CFG; +require_once("{$CFG->dirroot}/reportbuilder/tests/helpers.php"); +require_once("{$CFG->libdir}/gradelib.php"); + +/** + * Course participants datasource tests + * + * @package core_course + * @covers \core_course\reportbuilder\datasource\participants + * @covers \core_course\reportbuilder\local\formatters\completion + * @covers \core_course\reportbuilder\local\formatters\enrolment + * @copyright 2022 David Matamoros + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class participants_test extends core_reportbuilder_testcase { + + /** + * Test participants datasource + */ + public function test_participants_datasource(): void { + global $DB; + $this->resetAfterTest(); + + $timestart = time() - DAYSECS; + $timeend = $timestart + 3 * DAYSECS; + $timecompleted = $timestart + 2 * DAYSECS; + $timelastaccess = time() + 4 * DAYSECS; + + $category = $this->getDataGenerator()->create_category(['name' => 'Music']); + $course = $this->getDataGenerator()->create_course([ + 'category' => $category->id, + 'fullname' => 'All about Lionel at the work place', + 'enablecompletion' => true, + 'startdate' => $timestart, + 'enddate' => $timeend, + ]); + + $user1 = self::getDataGenerator()->create_user(); + $this->getDataGenerator()->enrol_user($user1->id, $course->id, 'student', + 'manual', $timestart, $timeend, ENROL_USER_ACTIVE); + + // Mark course as completed for the user. + $ccompletion = new completion_completion(array('course' => $course->id, 'userid' => $user1->id)); + $ccompletion->mark_enrolled($timestart); + $ccompletion->mark_complete($timecompleted); + + // Update final grade for the user. + $courseitem = grade_item::fetch_course_item($course->id); + $courseitem->update_final_grade($user1->id, 80); + + // Set some last access value for the user in the course. + $DB->insert_record('user_lastaccess', + ['userid' => $user1->id, 'courseid' => $course->id, 'timeaccess' => $timelastaccess]); + + /** @var core_reportbuilder_generator $generator */ + $generator = $this->getDataGenerator()->get_plugin_generator('core_reportbuilder'); + $report = $generator->create_report(['name' => 'Courses', 'source' => participants::class, 'default' => false]); + + $generator->create_column(['reportid' => $report->get('id'), + 'uniqueidentifier' => 'course:fullname']); + $generator->create_column(['reportid' => $report->get('id'), + 'uniqueidentifier' => 'course_category:name']); + $generator->create_column(['reportid' => $report->get('id'), + 'uniqueidentifier' => 'user:fullname']); + // Order by enrolment method. + $generator->create_column(['reportid' => $report->get('id'), + 'uniqueidentifier' => 'enrolment:method']) + ->set('sortenabled', true) + ->update(); + $generator->create_column(['reportid' => $report->get('id'), + 'uniqueidentifier' => 'completion:completed']); + $generator->create_column(['reportid' => $report->get('id'), + 'uniqueidentifier' => 'access:timeaccess']); + $generator->create_column(['reportid' => $report->get('id'), + 'uniqueidentifier' => 'completion:progresspercent']); + $generator->create_column(['reportid' => $report->get('id'), + 'uniqueidentifier' => 'completion:timeenrolled']); + $generator->create_column(['reportid' => $report->get('id'), + 'uniqueidentifier' => 'completion:timestarted']); + $generator->create_column(['reportid' => $report->get('id'), + 'uniqueidentifier' => 'completion:timecompleted']); + $generator->create_column(['reportid' => $report->get('id'), + 'uniqueidentifier' => 'completion:reaggregate']); + $generator->create_column(['reportid' => $report->get('id'), + 'uniqueidentifier' => 'completion:dayscourse']); + $generator->create_column(['reportid' => $report->get('id'), + 'uniqueidentifier' => 'completion:daysuntilcompletion']); + $generator->create_column(['reportid' => $report->get('id'), + 'uniqueidentifier' => 'completion:grade']); + + // Add filter to the report. + $generator->create_filter(['reportid' => $report->get('id'), 'uniqueidentifier' => 'enrolment:method']); + + $content = $this->get_custom_report_content($report->get('id')); + + // It should get 3 records (manual enrolment, self and guest). + $this->assertCount(3, $content); + + // Filter by Manual enrolment method. + $content = $this->get_custom_report_content($report->get('id'), 30, [ + 'enrolment:method_operator' => select::EQUAL_TO, + 'enrolment:method_value' => 'manual', + ]); + + $this->assertCount(1, $content); + + $this->assertEquals([ + 'All about Lionel at the work place', // Course name. + 'Music', // Course category name. + fullname($user1), // User fullname. + 'Manual enrolments', // Enrolment method. + 'Yes', // Course completed. + userdate($timelastaccess), // Time last access. + '100.0%', // Progress percentage. + userdate($timestart), // Time enrolled. + '', // Time started. + userdate($timecompleted), // Time completed. + '', // Reagreggate. + '2', // Days taking course. + '2', // Days until completion. + '80.00', // Grade. + ], array_values($content[0])); + } + + /** + * Data provider for {@see test_report_filters} + * + * @return array + */ + public function filters_data_provider(): array { + return [ + [ + 'enrolment:status', + [ + 'enrolment:status_operator' => select::EQUAL_TO, + 'enrolment:status_value' => 1, + ], + 'Luna' + ], + [ + 'enrolment:timecreated', + [ + 'enrolment:timecreated_operator' => date::DATE_CURRENT, + 'enrolment:timecreated_unit' => date::DATE_UNIT_DAY, + ], + 'Kira' + ], + [ + 'enrolment:timestarted', + [ + 'enrolment:timestarted_operator' => date::DATE_CURRENT, + 'enrolment:timecreated_unit' => date::DATE_UNIT_DAY, + ], + 'Luna' + ], + [ + 'enrolment:timeended', + [ + 'enrolment:timeended_operator' => date::DATE_CURRENT, + 'enrolment:timeended_unit' => date::DATE_UNIT_DAY, + ], + 'Luna' + ], + [ + 'completion:completed', + [ + 'completion:completed_operator' => boolean_select::CHECKED, + 'completion:completed_unit' => 1, + ], + 'Lionel' + ], + [ + 'completion:timecompleted', + [ + 'completion:timecompleted_operator' => date::DATE_NOT_EMPTY, + ], + 'Lionel' + ], + ]; + } + + /** + * Test getting filter SQL + * + * @param string $filter + * @param array $filtervalues + * @param string $expected + * + * @dataProvider filters_data_provider + */ + public function test_report_filters(string $filter, array $filtervalues, string $expected): void { + global $DB; + $this->resetAfterTest(); + + $timestart = time() - DAYSECS; + $timeend = $timestart + 3 * DAYSECS; + $timecompleted = $timestart + 2 * DAYSECS; + $timelastaccess = time() + 4 * DAYSECS; + + $category = $this->getDataGenerator()->create_category(['name' => 'Music']); + $course = $this->getDataGenerator()->create_course([ + 'category' => $category->id, + 'fullname' => 'All about Lionel at the work place', + 'enablecompletion' => true, + 'startdate' => $timestart, + 'enddate' => $timeend, + ]); + + $user1 = self::getDataGenerator()->create_user(['firstname' => 'Lionel']); + $user2 = self::getDataGenerator()->create_user(['firstname' => 'Kira']); + $user3 = self::getDataGenerator()->create_user(['firstname' => 'Luna']); + + $this->getDataGenerator()->enrol_user($user1->id, $course->id, 'student', + 'manual', $timestart - 8 * DAYSECS, $timeend, ENROL_USER_ACTIVE); + $this->getDataGenerator()->enrol_user($user2->id, $course->id, 'student', + 'manual', $timestart, $timeend, ENROL_USER_ACTIVE); + $this->getDataGenerator()->enrol_user($user3->id, $course->id, 'student', + 'manual', time(), time(), ENROL_USER_SUSPENDED); + + // Mark course as completed for the user. + $ccompletion = new completion_completion(array('course' => $course->id, 'userid' => $user1->id)); + $ccompletion->mark_enrolled($timestart); + $ccompletion->mark_complete($timecompleted); + + // Set some last access value for the user in the course. + $DB->insert_record('user_lastaccess', + ['userid' => $user1->id, 'courseid' => $course->id, 'timeaccess' => $timelastaccess]); + + /** @var core_reportbuilder_generator $generator */ + $generator = $this->getDataGenerator()->get_plugin_generator('core_reportbuilder'); + $report = $generator->create_report(['name' => 'Courses', 'source' => participants::class, 'default' => false]); + + // Add user firstname column to the report. + $generator->create_column(['reportid' => $report->get('id'), 'uniqueidentifier' => 'user:firstname']); + + $DB->set_field('user_enrolments', 'timecreated', 0, ['userid' => $user1->id]); + $DB->set_field('user_enrolments', 'timecreated', 0, ['userid' => $user3->id]); + + // Add filters to the report. + $generator->create_filter(['reportid' => $report->get('id'), 'uniqueidentifier' => 'enrolment:method']); + $generator->create_filter(['reportid' => $report->get('id'), 'uniqueidentifier' => $filter]); + + // Apply filters. + $filtermanual = ['enrolment:method_operator' => select::EQUAL_TO, 'enrolment:method_value' => 'manual']; + $content = $this->get_custom_report_content($report->get('id'), 30, $filtermanual + $filtervalues); + + $this->assertCount(1, $content); + $this->assertEquals($expected, $content[0]['c0_firstname']); + } +} diff --git a/lang/en/completion.php b/lang/en/completion.php index e725a150ca9..041a1bd372a 100644 --- a/lang/en/completion.php +++ b/lang/en/completion.php @@ -141,6 +141,7 @@ $string['csvdownload'] = 'Download in spreadsheet format (UTF-8 .csv)'; $string['datepassed'] = 'Date passed'; $string['days'] = 'Days'; $string['daysoftotal'] = '{$a->days} of {$a->total}'; +$string['daysuntilcompletion'] = 'Days until completion'; $string['defaultcompletion'] = 'Default activity completion'; $string['defaultcompletionupdated'] = 'Changes saved'; $string['deletecompletiondata'] = 'Delete completion data'; @@ -232,6 +233,7 @@ $string['selfcompletion'] = 'Self completion'; $string['showcompletionconditions'] = 'Show activity completion conditions'; $string['showcompletionconditions_help'] = 'Activity completion conditions are always shown on the activity page. This setting determines whether activity completion conditions are also shown below each activity on the course page.'; $string['showinguser'] = 'Showing user'; +$string['timecompleted'] = 'Time completed'; $string['todo'] = 'To do'; $string['unenrolingfromcourse'] = 'Unenrolling from course'; $string['unenrolment'] = 'Unenrolment'; diff --git a/lang/en/course.php b/lang/en/course.php index 72a05b8f2d3..9026b45803d 100644 --- a/lang/en/course.php +++ b/lang/en/course.php @@ -60,6 +60,7 @@ $string['completion_setby:auto:todo'] = 'To do: {$a->condition} (set by {$a->set $string['completion_setby:manual:done'] = '{$a->activityname} is marked by {$a->setby} as done. Press to undo.'; $string['completion_setby:manual:markdone'] = '{$a->activityname} is marked by {$a->setby} as not done. Press to mark as done.'; $string['completionrequirements'] = 'Completion requirements for {$a}'; +$string['courseaccess'] = 'Course access'; $string['coursealreadyfinished'] = 'Course already finished'; $string['coursecontentnotification'] = 'Send content change notification'; $string['coursecontentnotifnew'] = '{$a->coursename} new content'; @@ -69,6 +70,7 @@ $string['coursecontentnotifupdatebody'] = '

{$a->moduletypename} course}"'; @@ -102,6 +103,7 @@ $string['invalidrole'] = 'Invalid role'; $string['invalidrequest'] = 'Invalid request'; $string['manageenrols'] = 'Manage enrol plugins'; $string['manageinstance'] = 'Manage'; +$string['method'] = 'Method'; $string['migratetomanual'] = 'Migrate to manual enrolments'; $string['nochange'] = 'No change'; $string['noexistingparticipants'] = 'No existing participants'; @@ -131,6 +133,10 @@ $string['startdatetoday'] = 'Today'; $string['synced'] = 'Synced'; $string['testsettings'] = 'Test settings'; $string['testsettingsheading'] = 'Test enrol settings - {$a}'; +$string['timeended'] = 'Time ended'; +$string['timeenrolled'] = 'Time enrolled'; +$string['timereaggregated'] = 'Time reaggregated'; +$string['timestarted'] = 'Time started'; $string['totalenrolledusers'] = '{$a} enrolled users'; $string['totalunenrolledusers'] = '{$a} unenrolled users'; $string['totalotherusers'] = '{$a} other users'; diff --git a/reportbuilder/tests/helpers.php b/reportbuilder/tests/helpers.php index 8e68fee7ade..38ceb04a716 100644 --- a/reportbuilder/tests/helpers.php +++ b/reportbuilder/tests/helpers.php @@ -16,6 +16,7 @@ declare(strict_types=1); +use core_reportbuilder\local\helpers\user_filter_manager; use core_reportbuilder\table\custom_report_table_view; /** @@ -32,11 +33,15 @@ abstract class core_reportbuilder_testcase extends advanced_testcase { * * @param int $reportid * @param int $pagesize + * @param array $filtervalues * @return array[] */ - protected function get_custom_report_content(int $reportid, int $pagesize = 30): array { + protected function get_custom_report_content(int $reportid, int $pagesize = 30, array $filtervalues = []): array { $records = []; + // Apply filter values. + user_filter_manager::set($reportid, $filtervalues); + // Create table instance. $table = custom_report_table_view::create($reportid); $table->setup(); diff --git a/reportbuilder/upgrade.txt b/reportbuilder/upgrade.txt index bb6908d6cf5..b1c8aaed0fe 100644 --- a/reportbuilder/upgrade.txt +++ b/reportbuilder/upgrade.txt @@ -31,3 +31,4 @@ Information provided here is intended especially for developers. * New report filter types: - `category` for reports containing course categories - `tags` for reports containing entities with support for core_tag API +* The helper method `get_custom_report_content()` now accepts a list of filters and applies them to the report \ No newline at end of file