From 542f2927f7b1c2200a903e67df2f001a5a5c644e Mon Sep 17 00:00:00 2001 From: Paul Holden Date: Tue, 10 Nov 2020 09:24:12 +0000 Subject: [PATCH 1/9] MDL-70794 reportbuilder: register new core sub-system. In addition to housekeeping for the new sub-system, define our table schema to be used in later persistents. --- lib/components.json | 1 + lib/db/install.xml | 23 ++++++++++++++++++++++- lib/db/upgrade.php | 32 ++++++++++++++++++++++++++++++++ lib/tests/component_test.php | 2 +- phpunit.xml.dist | 3 +++ version.php | 2 +- 6 files changed, 60 insertions(+), 3 deletions(-) diff --git a/lib/components.json b/lib/components.json index ff292383d74..a9a635fd6ef 100644 --- a/lib/components.json +++ b/lib/components.json @@ -104,6 +104,7 @@ "privacy": "privacy", "question": "question", "rating": "rating", + "reportbuilder": "reportbuilder", "repository": "repository", "rss": "rss", "role": "admin\/roles", diff --git a/lib/db/install.xml b/lib/db/install.xml index 4aa9583d222..eeb1af648de 100644 --- a/lib/db/install.xml +++ b/lib/db/install.xml @@ -1,5 +1,5 @@ - @@ -4376,5 +4376,26 @@ + + + + + + + + + + + + + + + + + + + + +
diff --git a/lib/db/upgrade.php b/lib/db/upgrade.php index 751081e6e8c..1a537a56bdb 100644 --- a/lib/db/upgrade.php +++ b/lib/db/upgrade.php @@ -2675,5 +2675,37 @@ function xmldb_main_upgrade($oldversion) { upgrade_main_savepoint(true, 2021060900.00); } + if ($oldversion < 2021072800.01) { + // Define table reportbuilder_report to be created. + $table = new xmldb_table('reportbuilder_report'); + + // Adding fields to table reportbuilder_report. + $table->add_field('id', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, XMLDB_SEQUENCE, null); + $table->add_field('source', XMLDB_TYPE_CHAR, '255', null, XMLDB_NOTNULL, null, null); + $table->add_field('type', XMLDB_TYPE_INTEGER, '2', null, XMLDB_NOTNULL, null, '0'); + $table->add_field('contextid', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, null); + $table->add_field('component', XMLDB_TYPE_CHAR, '100', null, XMLDB_NOTNULL, null, null); + $table->add_field('area', XMLDB_TYPE_CHAR, '100', null, XMLDB_NOTNULL, null, null); + $table->add_field('itemid', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, '0'); + $table->add_field('usercreated', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, '0'); + $table->add_field('usermodified', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, '0'); + $table->add_field('timecreated', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, '0'); + $table->add_field('timemodified', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, '0'); + + // Adding keys to table reportbuilder_report. + $table->add_key('primary', XMLDB_KEY_PRIMARY, ['id']); + $table->add_key('usercreated', XMLDB_KEY_FOREIGN, ['usercreated'], 'user', ['id']); + $table->add_key('usermodified', XMLDB_KEY_FOREIGN, ['usermodified'], 'user', ['id']); + $table->add_key('contextid', XMLDB_KEY_FOREIGN, ['contextid'], 'context', ['id']); + + // Conditionally launch create table for reportbuilder_report. + if (!$dbman->table_exists($table)) { + $dbman->create_table($table); + } + + // Main savepoint reached. + upgrade_main_savepoint(true, 2021072800.01); + } + return true; } diff --git a/lib/tests/component_test.php b/lib/tests/component_test.php index f0d570398e5..f9ec91db5fe 100644 --- a/lib/tests/component_test.php +++ b/lib/tests/component_test.php @@ -36,7 +36,7 @@ class component_test extends advanced_testcase { * this is defined here to annoy devs that try to add more without any thinking, * always verify that it does not collide with any existing add-on modules and subplugins!!! */ - const SUBSYSTEMCOUNT = 73; + const SUBSYSTEMCOUNT = 74; public function setUp(): void { $psr0namespaces = new ReflectionProperty('core_component', 'psr0namespaces'); diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 0a152257a9d..55ed5258171 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -202,6 +202,9 @@ contentbank/tests + + reportbuilder/tests + diff --git a/version.php b/version.php index 923dd8dabce..84de88665ef 100644 --- a/version.php +++ b/version.php @@ -29,7 +29,7 @@ defined('MOODLE_INTERNAL') || die(); -$version = 2021072800.00; // YYYYMMDD = weekly release date of this DEV branch. +$version = 2021072800.01; // YYYYMMDD = weekly release date of this DEV branch. // RR = release increments - 00 in DEV branches. // .XX = incremental changes. $release = '4.0dev (Build: 20210728)'; // Human-friendly version name From 907ff8555f8665b3eb69c641ead1a148f62badc1 Mon Sep 17 00:00:00 2001 From: Paul Holden Date: Mon, 11 Jan 2021 16:05:40 +0000 Subject: [PATCH 2/9] MDL-70794 reportbuilder: implement report column and action classes. The column class encapsulates all data related to a report column. It allows developers to define the type of data contained within it, in addition to how that data is retrieved and formatted for users. Report actions are used to define action elements that are added to each report row. They define an icon and link, and can be used to direct users to related pages according to the current row data. --- .../classes/local/helpers/database.php | 77 +++ reportbuilder/classes/local/report/action.php | 131 +++++ reportbuilder/classes/local/report/column.php | 544 ++++++++++++++++++ reportbuilder/tests/coverage.php | 43 ++ .../tests/local/helpers/database_test.php | 81 +++ .../tests/local/report/action_test.php | 103 ++++ .../tests/local/report/column_test.php | 423 ++++++++++++++ 7 files changed, 1402 insertions(+) create mode 100644 reportbuilder/classes/local/helpers/database.php create mode 100644 reportbuilder/classes/local/report/action.php create mode 100644 reportbuilder/classes/local/report/column.php create mode 100644 reportbuilder/tests/coverage.php create mode 100644 reportbuilder/tests/local/helpers/database_test.php create mode 100644 reportbuilder/tests/local/report/action_test.php create mode 100644 reportbuilder/tests/local/report/column_test.php diff --git a/reportbuilder/classes/local/helpers/database.php b/reportbuilder/classes/local/helpers/database.php new file mode 100644 index 00000000000..e391504a44f --- /dev/null +++ b/reportbuilder/classes/local/helpers/database.php @@ -0,0 +1,77 @@ +. + +declare(strict_types=1); + +namespace core_reportbuilder\local\helpers; + +use coding_exception; + +/** + * Helper functions for DB manipulations + * + * @package core_reportbuilder + * @copyright 2019 Marina Glancy + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class database { + + /** @var string Prefix for generated aliases */ + private const GENERATE_ALIAS_PREFIX = 'rbalias'; + + /** @var string Prefix for generated param names names */ + private const GENERATE_PARAM_PREFIX = 'rbparam'; + + /** + * Generates unique table/column alias that must be used in generated SQL + * + * @return string + */ + public static function generate_alias(): string { + static $aliascount = 0; + + return static::GENERATE_ALIAS_PREFIX . ($aliascount++); + } + /** + * Generates unique parameter name that must be used in generated SQL + * + * @return string + */ + public static function generate_param_name(): string { + static $paramcount = 0; + + return static::GENERATE_PARAM_PREFIX . ($paramcount++); + } + + /** + * Validate that parameter names were generated using {@see generate_param_name}. + * + * @param array $params + * @return bool + * @throws coding_exception For invalid params. + */ + public static function validate_params(array $params): bool { + $nonmatchingkeys = array_filter($params, static function($key): bool { + return !preg_match('/^' . static::GENERATE_PARAM_PREFIX . '[\d]+/', $key); + }, ARRAY_FILTER_USE_KEY); + + if (!empty($nonmatchingkeys)) { + throw new coding_exception('Invalid parameter names', implode(', ', array_keys($nonmatchingkeys))); + } + + return true; + } +} diff --git a/reportbuilder/classes/local/report/action.php b/reportbuilder/classes/local/report/action.php new file mode 100644 index 00000000000..0add515e354 --- /dev/null +++ b/reportbuilder/classes/local/report/action.php @@ -0,0 +1,131 @@ +. + +declare(strict_types=1); + +namespace core_reportbuilder\local\report; + +use moodle_url; +use pix_icon; +use popup_action; +use stdClass; + +/** + * Class to represent a report action + * + * @package core_reportbuilder + * @copyright 2021 Paul Holden + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +final class action { + + /** @var moodle_url $url */ + protected $url; + + /** @var pix_icon $icon */ + protected $icon; + + /** @var array $attributes */ + protected $attributes; + + /** @var bool $popup */ + protected $popup; + + /** @var callable[] $callbacks */ + protected $callbacks = []; + + /** + * Create an instance of an action to be added to a report. Both the parameters of the URL, and the attributes parameter + * support placeholders which will be replaced with appropriate row values, e.g.: + * + * new action(new moodle_url('/', ['id' => ':id']), new pix_icon(...), ['data-id' => ':id']) + * + * Note that all expected placeholders should be added as base fields to the report + * + * @param moodle_url $url + * @param pix_icon $icon + * @param array $attributes + * @param bool $popup + */ + public function __construct( + moodle_url $url, + pix_icon $icon, + array $attributes = [], + bool $popup = false + ) { + $this->url = $url; + $this->icon = $icon; + $this->attributes = $attributes; + $this->popup = $popup; + } + + /** + * Adds callback to the action. Used to verify action is available to current user, or preprocess values used in placeholders + * + * Multiple callbacks can be added. If at least one returns false then the action will not be displayed + * + * @param callable $callback + * @return self + */ + public function add_callback(callable $callback): self { + $this->callbacks[] = $callback; + return $this; + } + + /** + * Return renderer action icon suitable for output + * + * @uses core_renderer::action_icon() + * + * @param stdClass $row + * @return string|null + */ + public function get_action_link(stdClass $row): ?string { + global $OUTPUT; + + foreach ($this->callbacks as $callback) { + $row = clone $row; // Clone so we don't modify the shared row inside a callback. + if (!$callback($row)) { + return null; + } + } + + // Create a new moodle_url instance with our filled in placeholders for this row. + $url = new moodle_url( + $this->url->out_omit_querystring(true), + self::replace_placeholders($this->url->params(), $row) + ); + + $popupaction = $this->popup ? new popup_action('click', $url) : null; + + return $OUTPUT->action_icon($url, $this->icon, $popupaction, self::replace_placeholders($this->attributes, $row)); + } + + /** + * Given an array of values, replace all placeholders with corresponding property of the given row + * + * @param array $values + * @param stdClass $row + * @return array + */ + private static function replace_placeholders(array $values, stdClass $row): array { + return array_map(static function($value) use ($row) { + return preg_replace_callback('/^:(?.*)$/', static function(array $matches) use ($row): string { + return (string) ($row->{$matches['property']} ?? ''); + }, $value); + }, $values); + } +} diff --git a/reportbuilder/classes/local/report/column.php b/reportbuilder/classes/local/report/column.php new file mode 100644 index 00000000000..af1d5fb477e --- /dev/null +++ b/reportbuilder/classes/local/report/column.php @@ -0,0 +1,544 @@ +. + +declare(strict_types=1); + +namespace core_reportbuilder\local\report; + +use coding_exception; +use lang_string; +use core_reportbuilder\local\helpers\database; + +/** + * Class to represent a report column + * + * @package core_reportbuilder + * @copyright 2020 Paul Holden + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +final class column { + + /** @var int Column type is integer */ + public const TYPE_INTEGER = 1; + + /** @var int Column type is text */ + public const TYPE_TEXT = 2; + + /** @var int Column type is timestamp */ + public const TYPE_TIMESTAMP = 3; + + /** @var int Column type is boolean */ + public const TYPE_BOOLEAN = 4; + + /** @var int Column type is float */ + public const TYPE_FLOAT = 5; + + /** @var int Column type is long text */ + public const TYPE_LONGTEXT = 6; + + /** @var int $index Column index within a report */ + private $index; + + /** @var string $columnname Internal reference to name of column */ + private $columnname; + + /** @var lang_string $columntitle Used as a title for the column in reports */ + private $columntitle; + + /** @var string $entityname Name of the entity this column belongs to */ + private $entityname; + + /** @var int $type Column data type (one of the TYPE_* class constants) */ + private $type = null; + + /** @var string[] $joins List of SQL joins for this column */ + private $joins = []; + + /** @var array $fields */ + private $fields = []; + + /** @var array $params */ + private $params = []; + + /** @var array[] $callbacks Array of [callable, additionalarguments] */ + private $callbacks = []; + + /** @var bool $issortable Used to indicate if a column is sortable */ + private $issortable = false; + + /** @var array $attributes */ + private $attributes = []; + + /** @var bool $available Used to know if column is available to the current user or not */ + protected $available = true; + + /** + * Column constructor + * + * For better readability use chainable methods, for example: + * + * $report->add_column( + * (new column('name', new lang_string('name'), 'user')) + * ->add_join('left join {table} t on t.id = p.tableid') + * ->add_field('t.name') + * ->add_callback([format::class, 'format_string'])); + * + * @param string $name Internal name of the column + * @param lang_string|null $title Title of the column used in reports (null for blank) + * @param string $entityname Name of the entity this column belongs to. Typically when creating columns within entities + * this value should be the result of calling {@see get_entity_name}, however if creating columns inside reports directly + * it should be the name of the entity as passed to {@see \core_reportbuilder\local\report\base::annotate_entity} + */ + public function __construct(string $name, ?lang_string $title, string $entityname) { + $this->columnname = $name; + $this->columntitle = $title; + $this->entityname = $entityname; + } + + /** + * Set column name + * + * @param string $name + * @return self + */ + public function set_name(string $name): self { + $this->columnname = $name; + return $this; + } + + /** + * Return column name + * + * @return mixed + */ + public function get_name(): string { + return $this->columnname; + } + + /** + * Set column title + * + * @param lang_string|null $title + * @return self + */ + public function set_title(?lang_string $title): self { + $this->columntitle = $title; + return $this; + } + + /** + * Return column title + * + * @return string + */ + public function get_title(): string { + return $this->columntitle ? (string) $this->columntitle : ''; + } + + /** + * Get column entity name + * + * @return string + */ + public function get_entity_name(): string { + return $this->entityname; + } + + + /** + * Return unique identifier for this column + * + * @return string + */ + public function get_unique_identifier(): string { + return $this->get_entity_name() . ':' . $this->get_name(); + } + + /** + * Set the column index within the current report + * + * @param int $index + * @return column + */ + public function set_index(int $index): self { + $this->index = $index; + return $this; + } + + /** + * Set the column type + * + * @param int $type + * @return column + * @throws coding_exception + */ + public function set_type(int $type): self { + $allowedtypes = [ + self::TYPE_INTEGER, + self::TYPE_TEXT, + self::TYPE_TIMESTAMP, + self::TYPE_BOOLEAN, + self::TYPE_FLOAT, + self::TYPE_LONGTEXT, + ]; + if (!in_array($type, $allowedtypes)) { + throw new coding_exception('Invalid column type', $type); + } + + $this->type = $type; + return $this; + } + + /** + * Return column type + * + * @return int|null + */ + public function get_type(): ?int { + return $this->type; + } + + /** + * Add join clause required for this column to join to existing tables/entities + * + * This is necessary in the case where {@see add_field} is selecting data from a table that isn't otherwise queried + * + * @param string $join + * @return self + */ + public function add_join(string $join): self { + $this->joins[trim($join)] = trim($join); + return $this; + } + + /** + * Add multiple join clauses required for this column, passing each to {@see add_join} + * + * Typically when defining columns in entities, you should pass {@see \core_reportbuilder\local\report\base::get_joins} to + * this method, so that all entity joins are included in the report when your column is added to it + * + * @param string[] $joins + * @return self + */ + public function add_joins(array $joins): self { + foreach ($joins as $join) { + $this->add_join($join); + } + return $this; + } + + /** + * Return column joins + * + * @return string[] + */ + public function get_joins(): array { + return array_values($this->joins); + } + + /** + * Adds a field to be queried from the database that is necessary for this column + * + * Multiple fields can be added per column, this method may be called several times. Field aliases must be unique inside + * any given column, but there will be no conflicts if the same aliases are used in other columns in the same report + * + * @param string $sql SQL query, this may be a simple "tablealias.fieldname" or a complex sub-query that returns only one field + * @param string $alias + * @param array $params + * @return self + * @throws coding_exception + */ + public function add_field(string $sql, string $alias = '', array $params = []): self { + database::validate_params($params); + + // SQL ends with a space and a word - this looks like an alias was passed as part of the field. + if (preg_match('/ \w+$/', $sql) && empty($alias)) { + throw new coding_exception('Column alias must be passed as a separate argument', $sql); + } + + // If no alias was specified, auto-detect it based on common patterns ("table.column" or just "column"). + if (empty($alias) && preg_match('/^(\w+\.)?(?\w+)$/', $sql, $matches)) { + $alias = $matches['fieldname']; + } + + if (empty($alias)) { + throw new coding_exception('Complex columns must have an alias', $sql); + } + + $this->fields[$alias] = $sql; + $this->params += $params; + + return $this; + } + + /** + * Add a list of comma-separated fields + * + * @param string $sql + * @param array $params + * @return self + */ + public function add_fields(string $sql, array $params = []): self { + database::validate_params($params); + + // Split SQL into separate fields (separated by comma). + $fields = preg_split('/\s*,\s*/', $sql); + foreach ($fields as $field) { + // Split each field into expression, where "as" and "alias" are optional. + $fieldparts = preg_split('/\s+/', $field); + + if (count($fieldparts) == 2 || (count($fieldparts) == 3 && strtolower($fieldparts[1]) === 'as')) { + $sql = reset($fieldparts); + $alias = array_pop($fieldparts); + $this->add_field($sql, $alias); + } else { + $this->add_field($field); + } + } + + $this->params += $params; + + return $this; + } + + /** + * Given a param name, add a unique prefix to ensure that the same column with params can be added multiple times to a report + * + * @param string $name + * @return string + */ + private function unique_param_name(string $name): string { + return "p{$this->index}_{$name}"; + } + + /** + * Helper method to take all fields added to the column, and return appropriate SQL and alias + * + * @return array[] + */ + private function get_fields_sql_alias(): array { + $fields = []; + + foreach ($this->fields as $alias => $sql) { + // Ensure params within SQL are prefixed with column index. + foreach ($this->params as $name => $value) { + $sql = preg_replace_callback('/:(?' . preg_quote($name, '\b/') . ')/', function(array $matches): string { + return ':' . $this->unique_param_name($matches['param']); + }, $sql); + } + + $fields[$alias] = [ + 'sql' => $sql, + 'alias' => substr("c{$this->index}_{$alias}", 0, 30), + ]; + } + + return $fields; + } + + /** + * Return array of SQL expressions for each field of this column + * + * @return array + */ + public function get_fields(): array { + $fields = array_map(static function(array $field): string { + return "{$field['sql']} AS {$field['alias']}"; + }, $this->get_fields_sql_alias()); + + return array_values($fields); + } + + /** + * Return column parameters, prefixed by the current index to allow the column to be added multiple times to a report + * + * @return array + */ + public function get_params(): array { + $params = []; + + foreach ($this->params as $name => $value) { + $paramname = $this->unique_param_name($name); + $params[$paramname] = $value; + } + + return $params; + } + + /** + * Return an alias for this column (the generated alias of it's first field) + * + * @return string + * @throws coding_exception + */ + public function get_column_alias(): string { + if (!$fields = $this->get_fields_sql_alias()) { + throw new coding_exception('Column ' . $this->get_unique_identifier() . ' contains no fields'); + } + + return reset($fields)['alias']; + } + + /** + * Adds column callback (in the case there are multiple, they will be applied one after another) + * + * The callback should implement the following signature (where $value is the first column field, $row is all column + * fields, and $additionalarguments are those passed on from this method): + * + * function($value, stdClass $row[, $additionalarguments]): string + * + * @param callable $callable function that takes arguments ($value, \stdClass $row, $additionalarguments) + * @param mixed $additionalarguments + * @return self + */ + public function add_callback(callable $callable, $additionalarguments = null): self { + $this->callbacks[] = [$callable, $additionalarguments]; + return $this; + } + + /** + * Sets column callback. This will overwrite any previously added callbacks {@see add_callback} + * + * @param callable $callable + * @param mixed $additionalarguments + * @return self + */ + public function set_callback(callable $callable, $additionalarguments = null): self { + $this->callbacks = []; + return $this->add_callback($callable, $additionalarguments); + } + + /** + * Sets the column as sortable + * + * @param bool $issortable + * @return self + */ + public function set_is_sortable(bool $issortable): self { + $this->issortable = $issortable; + return $this; + } + + /** + * Return sortable status of column + * + * @return bool + */ + public function get_is_sortable(): bool { + return $this->issortable; + } + + /** + * Extract all values from given row for this column + * + * @param array $row + * @return array + */ + private function get_values(array $row): array { + $values = []; + + foreach ($this->get_fields_sql_alias() as $alias => $field) { + $values[$alias] = $row[$field['alias']]; + } + + return $values; + } + + /** + * Return the default column value, that being the value of it's first field + * + * @param array $values + * @return mixed + */ + private function get_default_value(array $values) { + $value = reset($values); + + // Ensure default value is cast to it's strict type. + switch ($this->get_type()) { + case self::TYPE_INTEGER: + case self::TYPE_TIMESTAMP: + $value = (int) $value; + break; + case self::TYPE_FLOAT: + $value = (float) $value; + break; + case self::TYPE_BOOLEAN: + $value = (bool) $value; + break; + } + + return $value; + } + + /** + * Return column value based on complete table row + * + * @param array $row + * @return mixed + */ + public function format_value(array $row) { + $values = $this->get_values($row); + $value = $this->get_default_value($values); + + // Loop through, and apply any defined callbacks. + foreach ($this->callbacks as $callback) { + $value = ($callback[0])($value, (object) $values, $callback[1]); + } + + return $value; + } + + /** + * Add column attributes (data-, class, etc.) that will be included in HTML when column is displayed + * + * @param array $attributes + * @return self + */ + public function add_attributes(array $attributes): self { + $this->attributes = $attributes + $this->attributes; + return $this; + } + + /** + * Returns the column HTML attributes + * + * @return array + */ + public function get_attributes(): array { + return $this->attributes; + } + + /** + * Return available state of the column for the current user. For instance the column may be added to a report with the + * expectation that only some users are able to see it + * + * @return bool + */ + public function get_is_available(): bool { + return $this->available; + } + + /** + * Conditionally set whether the column is available. + * + * @param bool $available + * @return self + */ + public function set_is_available(bool $available): self { + $this->available = $available; + return $this; + } +} diff --git a/reportbuilder/tests/coverage.php b/reportbuilder/tests/coverage.php new file mode 100644 index 00000000000..1948492a919 --- /dev/null +++ b/reportbuilder/tests/coverage.php @@ -0,0 +1,43 @@ +. + +/** + * Coverage information for core_reportbuilder + * + * @package core_reportbuilder + * @copyright 2020 Paul Holden + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +declare(strict_types=1); + +defined('MOODLE_INTERNAL') || die(); + +return new class extends phpunit_coverage_info { + /** @var array The list of folders relative to the plugin root to include in coverage generation. */ + protected $includelistfolders = [ + 'classes', + ]; + + /** @var array The list of files relative to the plugin root to include in coverage generation. */ + protected $includelistfiles = []; + + /** @var array The list of folders relative to the plugin root to exclude in coverage generation. */ + protected $excludelistfolders = []; + + /** @var array The list of files relative to the plugin root to exclude in coverage generation. */ + protected $excludelistfiles = []; +}; diff --git a/reportbuilder/tests/local/helpers/database_test.php b/reportbuilder/tests/local/helpers/database_test.php new file mode 100644 index 00000000000..24df801539e --- /dev/null +++ b/reportbuilder/tests/local/helpers/database_test.php @@ -0,0 +1,81 @@ +. + +declare(strict_types=1); + +namespace core_reportbuilder\local\helpers; + +use advanced_testcase; +use coding_exception; +use core_user; + +/** + * Unit tests for the database helper class + * + * @package core_reportbuilder + * @covers \core_reportbuilder\local\helpers\database + * @copyright 2020 Paul Holden + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class database_testcase extends advanced_testcase { + + /** + * Test generating table alias and parameter names + */ + public function test_generate_alias_params(): void { + global $DB; + + $admin = core_user::get_user_by_username('admin'); + + $usertablealias = database::generate_alias(); + $usertablealiasjoin = database::generate_alias(); + $useridalias = database::generate_alias(); + + $paramuserid = database::generate_param_name(); + $paramuserdeleted = database::generate_param_name(); + + // Ensure they are different. + $this->assertNotEquals($usertablealias, $usertablealiasjoin); + $this->assertNotEquals($paramuserid, $paramuserdeleted); + + $sql = "SELECT {$usertablealias}.id AS {$useridalias} + FROM {user} {$usertablealias} + JOIN {user} {$usertablealiasjoin} ON {$usertablealiasjoin}.id = {$usertablealias}.id + WHERE {$usertablealias}.id = :{$paramuserid} AND {$usertablealias}.deleted = :{$paramuserdeleted}"; + $params = [$paramuserid => $admin->id, $paramuserdeleted => 0]; + + $validated = database::validate_params($params); + $this->assertTrue($validated); + + $record = $DB->get_record_sql($sql, $params); + $this->assertEquals($admin->id, $record->{$useridalias}); + } + + /** + * Test parameter validation + */ + public function test_validate_params(): void { + $params = [ + database::generate_param_name() => 1, + 'invalidfoo' => 2, + 'invalidbar' => 4, + ]; + + $this->expectException(coding_exception::class); + $this->expectExceptionMessage('Invalid parameter names (invalidfoo, invalidbar)'); + database::validate_params($params); + } +} diff --git a/reportbuilder/tests/local/report/action_test.php b/reportbuilder/tests/local/report/action_test.php new file mode 100644 index 00000000000..428c3b65a06 --- /dev/null +++ b/reportbuilder/tests/local/report/action_test.php @@ -0,0 +1,103 @@ +. + +declare(strict_types=1); + +namespace core_reportbuilder\local\report; + +use advanced_testcase; +use moodle_url; +use pix_icon; +use stdClass; + +/** + * Unit tests for a report action + * + * @package core_reportbuilder + * @covers \core_reportbuilder\local\report\action + * @copyright 2021 Paul Holden + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class action_testcase extends advanced_testcase { + + /** + * Test adding a callback that returns true + */ + public function test_add_callback_true(): void { + $action = $this->create_action() + ->add_callback(static function(stdClass $row): bool { + return true; + }); + + $this->assertNotNull($action->get_action_link(new stdClass())); + } + + /** + * Test adding a callback that returns false + */ + public function test_add_callback_false(): void { + $action = $this->create_action() + ->add_callback(static function(stdClass $row): bool { + return false; + }); + + $this->assertNull($action->get_action_link(new stdClass())); + } + + /** + * Test that action link URL parameters have placeholders replaced + */ + public function test_get_action_link_url_parameters(): void { + $action = $this->create_action(['id' => ':id', 'action' => 'edit']); + $actionlink = $action->get_action_link((object) ['id' => 42]); + + // This is the action URL we expect. + $expectedactionurl = (new moodle_url('/', ['id' => 42, 'action' => 'edit']))->out(false); + $this->assertStringContainsString("href=\"{$expectedactionurl}\"", $actionlink); + } + + /** + * Test that action link attributes have placeholders replaced + */ + public function test_get_action_link_attributes(): void { + $action = $this->create_action([], ['data-id' => ':id', 'data-action' => 'edit']); + $actionlink = $action->get_action_link((object) ['id' => 42]); + + // We expect each of these attributes to exist. + $expectedattributes = [ + 'data-id' => 42, + 'data-action' => 'edit', + ]; + foreach ($expectedattributes as $key => $value) { + $this->assertStringContainsString("{$key}=\"{$value}\"", $actionlink); + } + } + + /** + * Helper method to create an action instance + * + * @param array $urlparams + * @param array $attributes + * @return action + */ + private function create_action(array $urlparams = [], array $attributes = []): action { + return new action( + new moodle_url('/', $urlparams), + new pix_icon('t/edit', get_string('edit')), + $attributes + ); + } +} diff --git a/reportbuilder/tests/local/report/column_test.php b/reportbuilder/tests/local/report/column_test.php new file mode 100644 index 00000000000..e1f48d6ec2b --- /dev/null +++ b/reportbuilder/tests/local/report/column_test.php @@ -0,0 +1,423 @@ +. + +declare(strict_types=1); + +namespace core_reportbuilder\local\report; + +use advanced_testcase; +use coding_exception; +use lang_string; +use stdClass; +use core_reportbuilder\local\helpers\database; + +/** + * Unit tests for a report column + * + * @package core_reportbuilder + * @covers \core_reportbuilder\local\report\column + * @copyright 2020 Paul Holden + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class column_testcase extends advanced_testcase { + + /** + * Test column name getter/setter + */ + public function test_name(): void { + $column = $this->create_column('test'); + $this->assertEquals('test', $column->get_name()); + + $this->assertEquals('another', $column + ->set_name('another') + ->get_name() + ); + } + + /** + * Test column title getter/setter + */ + public function test_title(): void { + $column = $this->create_column('test', new lang_string('show')); + $this->assertEquals('Show', $column->get_title()); + + $this->assertEquals('Hide', $column + ->set_title(new lang_string('hide')) + ->get_title() + ); + + // Column titles can also be empty. + $this->assertEmpty($column + ->set_title(null) + ->get_title()); + } + + /** + * Test entity name getter + */ + public function test_get_entity_name(): void { + $column = $this->create_column('test', null, 'entityname'); + $this->assertEquals('entityname', $column->get_entity_name()); + } + + /** + * Test getting unique identifier + */ + public function test_get_unique_identifier(): void { + $column = $this->create_column('test', null, 'entityname'); + $this->assertEquals('entityname:test', $column->get_unique_identifier()); + } + + /** + * Test column type getter/setter + */ + public function test_type(): void { + $column = $this->create_column('test'); + $this->assertEquals(column::TYPE_TEXT, $column + ->set_type(column::TYPE_TEXT) + ->get_type()); + } + + /** + * Test column type with invalid value + */ + public function test_type_invalid(): void { + $column = $this->create_column('test'); + + $this->expectException(coding_exception::class); + $this->expectExceptionMessage('Invalid column type'); + $column->set_type(-1); + } + + /** + * Test adding single join + */ + public function test_add_join(): void { + $column = $this->create_column('test'); + $this->assertEquals([], $column->get_joins()); + + $column->add_join('JOIN {user} u ON u.id = table.userid'); + $this->assertEquals(['JOIN {user} u ON u.id = table.userid'], $column->get_joins()); + } + + /** + * Test adding multiple joins + */ + public function test_add_joins(): void { + $tablejoins = [ + "JOIN {course} c2 ON c2.id = c1.id", + "JOIN {course} c3 ON c3.id = c1.id", + ]; + + $column = $this->create_column('test') + ->add_joins($tablejoins); + + $this->assertEquals($tablejoins, $column->get_joins()); + } + + /** + * Data provider for {@see test_add_field} + * + * @return array + */ + public function add_field_provider(): array { + return [ + ['foo', '', ['foo AS c1_foo']], + ['foo', 'bar', ['foo AS c1_bar']], + ['t.foo', '', ['t.foo AS c1_foo']], + ['t.foo', 'bar', ['t.foo AS c1_bar']], + ]; + } + + /** + * Test adding single field, and retrieving it + * + * @param string $sql + * @param string $alias + * @param array $expectedselect + * + * @dataProvider add_field_provider + */ + public function test_add_field(string $sql, string $alias, array $expectedselect): void { + $column = $this->create_column('test') + ->set_index(1) + ->add_field($sql, $alias); + + $this->assertEquals($expectedselect, $column->get_fields()); + } + + /** + * Test adding params to field, and retrieving them + */ + public function test_add_field_with_params(): void { + $param = database::generate_param_name(); + + $column = $this->create_column('test') + ->set_index(1) + ->add_field(":{$param}", 'foo', [$param => 'bar']); + + // Select will look like the following: "p_rbparam", where index is the column index and counter is + // a static value of the report helper class. + $select = $column->get_fields(); + preg_match('/:(?p1_rbparam[\d]+) AS c1_foo/', $select[0], $matches); + + $this->assertArrayHasKey('paramname', $matches); + $this->assertEquals([$matches['paramname'] => 'bar'], $column->get_params()); + } + + /** + * Test adding field with alias as part of SQL throws an exception + */ + public function test_add_field_alias_in_sql(): void { + $column = $this->create_column('test') + ->set_index(1); + + $this->expectException(coding_exception::class); + $this->expectExceptionMessage('Column alias must be passed as a separate argument'); + $column->add_field('foo AS bar'); + } + + /** + * Test adding field with complex SQL without an alias throws an exception + */ + public function test_add_field_complex_without_alias(): void { + global $DB; + + $column = $this->create_column('test') + ->set_index(1); + + $this->expectException(coding_exception::class); + $this->expectExceptionMessage('Complex columns must have an alias'); + $column->add_field($DB->sql_concat('foo', 'bar')); + } + + /** + * Data provider for {@see test_add_fields} + * + * @return array + */ + public function add_fields_provider(): array { + return [ + ['t.foo', ['t.foo AS c1_foo']], + ['t.foo bar', ['t.foo AS c1_bar']], + ['t.foo AS bar', ['t.foo AS c1_bar']], + ['t.foo1, t.foo2 bar, t.foo3 AS baz', ['t.foo1 AS c1_foo1', 't.foo2 AS c1_bar', 't.foo3 AS c1_baz']], + ]; + } + + /** + * Test adding fields to a column, and retrieving them + * + * @param string $sql + * @param array $expectedselect + * + * @dataProvider add_fields_provider + */ + public function test_add_fields(string $sql, array $expectedselect): void { + $column = $this->create_column('test') + ->set_index(1) + ->add_fields($sql); + + $this->assertEquals($expectedselect, $column->get_fields()); + } + + /** + * Test column alias + */ + public function test_column_alias(): void { + $column = $this->create_column('test') + ->set_index(1) + ->add_fields('t.foo, t.bar'); + + $this->assertEquals('c1_foo', $column->get_column_alias()); + } + + /** + * Test alias of column without any fields throws exception + */ + public function test_column_alias_no_fields(): void { + $column = $this->create_column('test'); + + $this->expectException(coding_exception::class); + $this->expectExceptionMessage('Column ' . $column->get_unique_identifier() . ' contains no fields'); + $column->add_field($column->get_column_alias()); + } + + /** + * Data provider for {@see test_format_value} + * + * @return array[] + */ + public function format_value_provider(): array { + return [ + [column::TYPE_INTEGER, 42], + [column::TYPE_TEXT, 'Hello'], + [column::TYPE_TIMESTAMP, HOURSECS], + [column::TYPE_BOOLEAN, 1, true], + [column::TYPE_FLOAT, 1.23], + [column::TYPE_LONGTEXT, 'Amigos'], + ]; + } + + /** + * Test that column value is returned as correctly (value plus type) + * + * @param int $columntype + * @param mixed $value + * @param mixed|null $expected Expected value, or null to indicate it should be identical to value + * + * @dataProvider format_value_provider + */ + public function test_format_value(int $columntype, $value, $expected = null): void { + $column = $this->create_column('test') + ->set_index(1) + ->set_type($columntype) + ->add_field('t.foo'); + + $this->assertSame($expected ?? $value, $column->format_value([ + 'c1_foo' => $value, + ])); + } + + /** + * Test that column value with callback is returned + */ + public function test_format_value_callback(): void { + $column = $this->create_column('test') + ->set_index(1) + ->add_field('t.foo') + ->set_type(column::TYPE_INTEGER) + ->add_callback(static function(int $value, stdClass $values) { + return $value * 2; + }); + + $this->assertEquals(84, $column->format_value([ + 'c1_bar' => 10, + 'c1_foo' => 42, + ])); + } + + /** + * Test that column value with callback (using all fields) is returned + */ + public function test_format_value_callback_fields(): void { + $column = $this->create_column('test') + ->set_index(1) + ->add_fields('t.foo, t.baz') + ->set_type(column::TYPE_INTEGER) + ->add_callback(static function(int $value, stdClass $values) { + return $values->foo + $values->baz; + }); + + $this->assertEquals(60, $column->format_value([ + 'c1_bar' => 10, + 'c1_foo' => 42, + 'c1_baz' => 18, + ])); + } + + /** + * Test that column value with callback (using arguments) is returned + */ + public function test_format_value_callback_arguments(): void { + $column = $this->create_column('test') + ->set_index(1) + ->add_field('t.foo') + ->set_type(column::TYPE_INTEGER) + ->add_callback(static function(int $value, stdClass $values, int $argument) { + return $value - $argument; + }, 10); + + $this->assertEquals(32, $column->format_value([ + 'c1_bar' => 10, + 'c1_foo' => 42, + ])); + } + + /** + * Test adding multiple callbacks to a column + */ + public function test_add_multiple_callback(): void { + $column = $this->create_column('test') + ->set_index(1) + ->add_field('t.foo') + ->set_type(column::TYPE_TEXT) + ->add_callback(static function(string $value): string { + return strrev($value); + }) + ->add_callback(static function(string $value): string { + return strtoupper($value); + }); + + $this->assertEquals('LIONEL', $column->format_value([ + 'c1_foo' => 'lenoil', + ])); + } + + /** + * Test that setting column callback overwrites previous callbacks + */ + public function test_set_callback(): void { + $column = $this->create_column('test') + ->set_index(1) + ->add_field('t.foo') + ->set_type(column::TYPE_TEXT) + ->add_callback(static function(string $value): string { + return strrev($value); + }) + ->set_callback(static function(string $value): string { + return strtoupper($value); + }); + + $this->assertEquals('LENOIL', $column->format_value([ + 'c1_foo' => 'lenoil', + ])); + } + + /** + * Test is sortable + */ + public function test_is_sortable(): void { + $column = $this->create_column('test'); + $this->assertFalse($column->get_is_sortable()); + + $column->set_is_sortable(true); + $this->assertTrue($column->get_is_sortable()); + } + + /** + * Test is available + */ + public function test_is_available(): void { + $column = $this->create_column('test'); + $this->assertTrue($column->get_is_available()); + + $column->set_is_available(true); + $this->assertTrue($column->get_is_available()); + } + + /** + * Helper method to create a column instance + * + * @param string $name + * @param lang_string|null $title + * @param string $entityname + * @return column + */ + private function create_column(string $name, ?lang_string $title = null, string $entityname = 'column_testcase'): column { + return new column($name, $title, $entityname); + } +} From 2a202389249effbde8eda1278de2546a3e1ad408 Mon Sep 17 00:00:00 2001 From: David Matamoros Date: Tue, 16 Mar 2021 11:12:10 +0000 Subject: [PATCH 3/9] MDL-70794 reportbuilder: implement report filters and filter types. Implement base filter classes as well as commonly used filter types (e.g. text, date, select, etc). Filters are used in reports to allow users to narrow down the data that is being displayed. Co-Authored-By: Paul Holden --- lang/en/reportbuilder.php | 47 +++ .../classes/external/filters/reset.php | 79 +++++ reportbuilder/classes/local/filters/base.php | 88 +++++ .../classes/local/filters/boolean_select.php | 104 ++++++ .../classes/local/filters/course_selector.php | 68 ++++ reportbuilder/classes/local/filters/date.php | 133 ++++++++ .../classes/local/filters/number.php | 199 +++++++++++ .../classes/local/filters/select.php | 134 ++++++++ reportbuilder/classes/local/filters/text.php | 193 +++++++++++ .../local/helpers/user_filter_manager.php | 175 ++++++++++ reportbuilder/classes/local/report/filter.php | 310 ++++++++++++++++++ .../tests/external/filters/reset_test.php | 61 ++++ .../local/filters/boolean_select_test.php | 86 +++++ .../local/filters/course_selector_test.php | 77 +++++ .../tests/local/filters/date_test.php | 117 +++++++ .../tests/local/filters/number_test.php | 148 +++++++++ .../tests/local/filters/select_test.php | 98 ++++++ .../tests/local/filters/text_test.php | 100 ++++++ .../helpers/user_filter_manager_test.php | 233 +++++++++++++ .../tests/local/report/filter_test.php | 219 +++++++++++++ 20 files changed, 2669 insertions(+) create mode 100644 lang/en/reportbuilder.php create mode 100644 reportbuilder/classes/external/filters/reset.php create mode 100644 reportbuilder/classes/local/filters/base.php create mode 100644 reportbuilder/classes/local/filters/boolean_select.php create mode 100644 reportbuilder/classes/local/filters/course_selector.php create mode 100644 reportbuilder/classes/local/filters/date.php create mode 100644 reportbuilder/classes/local/filters/number.php create mode 100644 reportbuilder/classes/local/filters/select.php create mode 100644 reportbuilder/classes/local/filters/text.php create mode 100644 reportbuilder/classes/local/helpers/user_filter_manager.php create mode 100644 reportbuilder/classes/local/report/filter.php create mode 100644 reportbuilder/tests/external/filters/reset_test.php create mode 100644 reportbuilder/tests/local/filters/boolean_select_test.php create mode 100644 reportbuilder/tests/local/filters/course_selector_test.php create mode 100644 reportbuilder/tests/local/filters/date_test.php create mode 100644 reportbuilder/tests/local/filters/number_test.php create mode 100644 reportbuilder/tests/local/filters/select_test.php create mode 100644 reportbuilder/tests/local/filters/text_test.php create mode 100644 reportbuilder/tests/local/helpers/user_filter_manager_test.php create mode 100644 reportbuilder/tests/local/report/filter_test.php diff --git a/lang/en/reportbuilder.php b/lang/en/reportbuilder.php new file mode 100644 index 00000000000..5d4c70efef6 --- /dev/null +++ b/lang/en/reportbuilder.php @@ -0,0 +1,47 @@ +. + +/** + * Strings for component 'reportbuilder', language 'en' + * + * @package core_reportbuilder + * @copyright 2020 Sara Arjona + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +$string['filtercontains'] = 'Contains'; +$string['filterdatefrom'] = 'Date from'; +$string['filterdateto'] = 'Date to'; +$string['filterdoesnotcontain'] = 'Does not contain'; +$string['filterendswith'] = 'Ends with'; +$string['filterequalorgreaterthan'] = 'Greater than or equal'; +$string['filterequalorlessthan'] = 'Less than or equal'; +$string['filterfieldoperator'] = '{$a} operator'; +$string['filterfieldvalue'] = '{$a} value'; +$string['filtergreaterthan'] = 'Greater than'; +$string['filterinvalid'] = 'Invalid filter'; +$string['filterisanyvalue'] = 'Is any value'; +$string['filterisempty'] = 'Is empty'; +$string['filterisequalto'] = 'Is equal to'; +$string['filterisnotempty'] = 'Is not empty'; +$string['filterisnotequalto'] = 'Is not equal to'; +$string['filterlessthan'] = 'Less than'; +$string['filterrange'] = 'Range'; +$string['filtersapplied'] = 'Filters applied'; +$string['filtersreset'] = 'Filters reset'; +$string['filterstartswith'] = 'Starts with'; +$string['privacy:metadata:preference:reportfilter'] = 'The stored report filters for this user'; +$string['selectcourses'] = 'Select courses'; diff --git a/reportbuilder/classes/external/filters/reset.php b/reportbuilder/classes/external/filters/reset.php new file mode 100644 index 00000000000..2dc22d1b4aa --- /dev/null +++ b/reportbuilder/classes/external/filters/reset.php @@ -0,0 +1,79 @@ +. + +declare(strict_types=1); + +namespace core_reportbuilder\external\filters; + +use context_system; +use external_api; +use external_function_parameters; +use external_value; +use core_reportbuilder\local\helpers\user_filter_manager; + +defined('MOODLE_INTERNAL') || die(); + +global $CFG; +require_once("{$CFG->libdir}/externallib.php"); + +/** + * External method for resetting report filters + * + * @package core_reportbuilder + * @copyright 2021 Paul Holden + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class reset extends external_api { + + /** + * External method parameters + * + * @return external_function_parameters + */ + public static function execute_parameters(): external_function_parameters { + return new external_function_parameters([ + 'reportid' => new external_value(PARAM_INT, 'Report ID'), + ]); + } + + /** + * External method execution + * + * @param int $reportid + * @return bool + */ + public static function execute(int $reportid): bool { + [ + 'reportid' => $reportid, + ] = self::validate_parameters(self::execute_parameters(), [ + 'reportid' => $reportid, + ]); + + $context = context_system::instance(); + self::validate_context($context); + + return user_filter_manager::reset_all($reportid); + } + + /** + * External method return value + * + * @return external_value + */ + public static function execute_returns(): external_value { + return new external_value(PARAM_BOOL, 'Success'); + } +} diff --git a/reportbuilder/classes/local/filters/base.php b/reportbuilder/classes/local/filters/base.php new file mode 100644 index 00000000000..2f279504ce5 --- /dev/null +++ b/reportbuilder/classes/local/filters/base.php @@ -0,0 +1,88 @@ +. + +declare(strict_types=1); + +namespace core_reportbuilder\local\filters; + +use MoodleQuickForm; +use core_reportbuilder\local\report\filter; + +/** + * Base class for all report filters + * + * Filters provide a form for collecting user input, and then return appropriate SQL fragments based on these values + * + * @package core_reportbuilder + * @copyright 2021 Paul Holden + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +abstract class base { + + /** @var filter $filter */ + protected $filter; + + /** @var string $name */ + protected $name; + + /** + * Do not allow the constructor to be called directly or overridden + * + * @param filter $filter + */ + private function __construct(filter $filter) { + $this->filter = $filter; + $this->name = $filter->get_unique_identifier(); + } + + /** + * Creates an instance of a filter type, based on supplied report filter instance + * + * The report filter instance is used by reports/entities to define what should be filtered against, e.g. a SQL fragment + * + * @param filter $filter The report filter instance + * @return static + */ + final public static function create(filter $filter): self { + $filterclass = $filter->get_filter_class(); + + return new $filterclass($filter); + } + + /** + * Returns the filter header + * + * @return string + */ + final public function get_header(): string { + return $this->filter->get_header(); + } + + /** + * Adds filter-specific form elements + * + * @param MoodleQuickForm $mform + */ + abstract public function setup_form(MoodleQuickForm $mform): void; + + /** + * Returns the filter clauses to be used with SQL where + * + * @param array $values + * @return array [$sql, [...$params]] + */ + abstract public function get_sql_filter(array $values): array; +} diff --git a/reportbuilder/classes/local/filters/boolean_select.php b/reportbuilder/classes/local/filters/boolean_select.php new file mode 100644 index 00000000000..92ae16b8daf --- /dev/null +++ b/reportbuilder/classes/local/filters/boolean_select.php @@ -0,0 +1,104 @@ +. + +declare(strict_types=1); + +namespace core_reportbuilder\local\filters; + +use lang_string; +use MoodleQuickForm; +use core_reportbuilder\local\helpers\database; + +/** + * Boolean report filter + * + * This filter accepts an expression that evaluates to 1 or 0, either a simple field such as "u.suspended", or a more complex + * expression such as "CASE WHEN THEN 1 ELSE 0 END" + * + * @package core_reportbuilder + * @copyright 2021 Paul Holden + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class boolean_select extends base { + + /** @var int Any value */ + public const ANY_VALUE = 0; + + /** @var int Checked */ + public const CHECKED = 1; + + /** @var int Not checked */ + public const NOT_CHECKED = 2; + + /** + * Return an array of operators available for this filter + * + * @return lang_string[] + */ + private function get_operators(): array { + $operators = [ + self::ANY_VALUE => new lang_string('filterisanyvalue', 'core_reportbuilder'), + self::CHECKED => new lang_string('yes'), + self::NOT_CHECKED => new lang_string('no'), + ]; + + return $this->filter->restrict_limited_operators($operators); + } + + /** + * Setup form + * + * @param MoodleQuickForm $mform + */ + public function setup_form(MoodleQuickForm $mform): void { + $operatorlabel = get_string('filterfieldoperator', 'core_reportbuilder', $this->get_header()); + $mform->addElement('select', "{$this->name}_operator", $operatorlabel, $this->get_operators()) + ->setHiddenLabel(true); + + $mform->setType("{$this->name}_operator", PARAM_INT); + $mform->setDefault("{$this->name}_operator", self::ANY_VALUE); + } + + /** + * Return filter SQL + * + * @param array $values + * @return array + */ + public function get_sql_filter(array $values): array { + $fieldsql = $this->filter->get_field_sql(); + $params = $this->filter->get_field_params(); + + $paramname = database::generate_param_name(); + + $operator = $values["{$this->name}_operator"] ?? self::ANY_VALUE; + switch ($operator) { + case self::CHECKED: + $fieldsql .= " = :{$paramname}"; + $params[$paramname] = 1; + break; + case self::NOT_CHECKED: + $fieldsql .= " = :{$paramname}"; + $params[$paramname] = 0; + break; + default: + // Invalid or inactive filter. + return ['', []]; + } + + return [$fieldsql, $params]; + } +} diff --git a/reportbuilder/classes/local/filters/course_selector.php b/reportbuilder/classes/local/filters/course_selector.php new file mode 100644 index 00000000000..8031612a92f --- /dev/null +++ b/reportbuilder/classes/local/filters/course_selector.php @@ -0,0 +1,68 @@ +. + +declare(strict_types=1); + +namespace core_reportbuilder\local\filters; + +use MoodleQuickForm; +use core_reportbuilder\local\helpers\database; + +/** + * Course selector filter class implementation + * + * @package core_reportbuilder + * @copyright 2021 David Matamoros . + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class course_selector extends base { + + /** + * Setup form + * + * @param MoodleQuickForm $mform + */ + public function setup_form(MoodleQuickForm $mform): void { + $options = [ + 'multiple' => true, + ]; + $mform->addElement('course', $this->name . '_values', get_string('selectcourses', 'core_reportbuilder'), $options) + ->setHiddenLabel(true); + } + + /** + * Return filter SQL + * + * @param array $values + * @return array + */ + public function get_sql_filter(array $values): array { + global $DB; + + $fieldsql = $this->filter->get_field_sql(); + $params = $this->filter->get_field_params(); + + $courseids = $values["{$this->name}_values"] ?? []; + if (empty($courseids)) { + return ['', []]; + } + + $paramprefix = database::generate_param_name() . '_'; + [$courseselect, $courseparams] = $DB->get_in_or_equal($courseids, SQL_PARAMS_NAMED, $paramprefix); + + return ["{$fieldsql} $courseselect", array_merge($params, $courseparams)]; + } +} diff --git a/reportbuilder/classes/local/filters/date.php b/reportbuilder/classes/local/filters/date.php new file mode 100644 index 00000000000..5d390e56ec8 --- /dev/null +++ b/reportbuilder/classes/local/filters/date.php @@ -0,0 +1,133 @@ +. + +declare(strict_types=1); + +namespace core_reportbuilder\local\filters; + +use lang_string; +use MoodleQuickForm; +use core_reportbuilder\local\helpers\database; + +/** + * Date report filter + * + * This filter accepts a unix timestamp to perform date filtering on + * + * @package core_reportbuilder + * @copyright 2021 Paul Holden + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class date extends base { + + /** @var int Any value */ + public const DATE_ANY = 0; + + /** @var int Non-empty (positive) value */ + public const DATE_NOT_EMPTY = 1; + + /** @var int Empty (zero) value */ + public const DATE_EMPTY = 2; + + /** @var int Date within defined range */ + public const DATE_RANGE = 3; + + /** + * Return an array of operators available for this filter + * + * @return lang_string[] + */ + private function get_operators(): array { + $operators = [ + self::DATE_ANY => new lang_string('filterisanyvalue', 'core_reportbuilder'), + self::DATE_NOT_EMPTY => new lang_string('filterisnotempty', 'core_reportbuilder'), + self::DATE_EMPTY => new lang_string('filterisempty', 'core_reportbuilder'), + self::DATE_RANGE => new lang_string('filterrange', 'core_reportbuilder'), + ]; + + return $this->filter->restrict_limited_operators($operators); + } + + /** + * Setup form + * + * @param MoodleQuickForm $mform + */ + public function setup_form(MoodleQuickForm $mform): void { + $operatorlabel = get_string('filterfieldoperator', 'core_reportbuilder', $this->get_header()); + $mform->addElement('select', "{$this->name}_operator", $operatorlabel, $this->get_operators())->setHiddenLabel(true); + $mform->setType("{$this->name}_operator", PARAM_INT); + $mform->setDefault("{$this->name}_operator", self::DATE_ANY); + + $mform->addElement('date_selector', "{$this->name}_from", get_string('filterdatefrom', 'core_reportbuilder'), + ['optional' => true]); + $mform->setType("{$this->name}_from", PARAM_INT); + $mform->setDefault("{$this->name}_from", 0); + $mform->hideIf("{$this->name}_from", "{$this->name}_operator", 'neq', self::DATE_RANGE); + + $mform->addElement('date_selector', "{$this->name}_to", get_string('filterdateto', 'core_reportbuilder'), + ['optional' => true]); + $mform->setType("{$this->name}_to", PARAM_INT); + $mform->setDefault("{$this->name}_to", 0); + $mform->hideIf("{$this->name}_to", "{$this->name}_operator", 'neq', self::DATE_RANGE); + } + + /** + * Return filter SQL + * + * @param array $values + * @return array + */ + public function get_sql_filter(array $values): array { + $fieldsql = $this->filter->get_field_sql(); + $params = $this->filter->get_field_params(); + + $operator = $values["{$this->name}_operator"] ?? self::DATE_ANY; + switch ($operator) { + case self::DATE_NOT_EMPTY: + $sql = "{$fieldsql} IS NOT NULL AND {$fieldsql} <> 0"; + break; + case self::DATE_EMPTY: + $sql = "{$fieldsql} IS NULL OR {$fieldsql} = 0"; + break; + case self::DATE_RANGE: + $clauses = []; + + $datefrom = (int)($values["{$this->name}_from"] ?? 0); + if ($datefrom > 0) { + $paramdatefrom = database::generate_param_name(); + $clauses[] = "{$fieldsql} >= :{$paramdatefrom}"; + $params[$paramdatefrom] = $datefrom; + } + + $dateto = (int)($values["{$this->name}_to"] ?? 0); + if ($dateto > 0) { + $paramdateto = database::generate_param_name(); + $clauses[] = "{$fieldsql} < :{$paramdateto}"; + $params[$paramdateto] = $dateto; + } + + $sql = implode(' AND ', $clauses); + + break; + default: + // Invalid or inactive filter. + return ['', []]; + } + + return [$sql, $params]; + } +} diff --git a/reportbuilder/classes/local/filters/number.php b/reportbuilder/classes/local/filters/number.php new file mode 100644 index 00000000000..73eb38983bc --- /dev/null +++ b/reportbuilder/classes/local/filters/number.php @@ -0,0 +1,199 @@ +. + +declare(strict_types=1); + +namespace core_reportbuilder\local\filters; + +use core_reportbuilder\local\helpers\database; + +/** + * Number report filter + * + * @package core_reportbuilder + * @copyright 2021 David Matamoros + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class number extends base { + + /** @var int Any value */ + public const ANY_VALUE = 0; + + /** @var int Is not empty */ + public const IS_NOT_EMPTY = 1; + + /** @var int Is empty */ + public const IS_EMPTY = 2; + + /** @var int Less than */ + public const LESS_THAN = 3; + + /** @var int Greater than */ + public const GREATER_THAN = 4; + + /** @var int Equal to */ + public const EQUAL_TO = 5; + + /** @var int Equal or less than */ + public const EQUAL_OR_LESS_THAN = 6; + + /** @var int Equal or greater than */ + public const EQUAL_OR_GREATER_THAN = 7; + + /** @var int Range */ + public const RANGE = 8; + + /** + * Returns an array of comparison operators + * + * @return array of comparison operators + */ + private function get_operators(): array { + $operators = [ + self::ANY_VALUE => get_string('filterisanyvalue', 'core_reportbuilder'), + self::IS_NOT_EMPTY => get_string('filterisnotempty', 'core_reportbuilder'), + self::IS_EMPTY => get_string('filterisempty', 'core_reportbuilder'), + self::LESS_THAN => get_string('filterlessthan', 'core_reportbuilder'), + self::GREATER_THAN => get_string('filtergreaterthan', 'core_reportbuilder'), + self::EQUAL_TO => get_string('filterisequalto', 'core_reportbuilder'), + self::EQUAL_OR_LESS_THAN => get_string('filterequalorlessthan', 'core_reportbuilder'), + self::EQUAL_OR_GREATER_THAN => get_string('filterequalorgreaterthan', 'core_reportbuilder'), + self::RANGE => get_string('filterrange', 'core_reportbuilder'), + ]; + + return $this->filter->restrict_limited_operators($operators); + } + + /** + * Adds controls specific to this filter in the form. + * + * @param \MoodleQuickForm $mform + */ + public function setup_form(\MoodleQuickForm $mform): void { + $objs = []; + + $objs['select'] = $mform->createElement('select', $this->name . '_operator', null, $this->get_operators()); + $mform->setType($this->name . '_operator', PARAM_INT); + + $objs['text'] = $mform->createElement('text', $this->name . '_value1', null, ['size' => 3]); + $mform->setType($this->name . '_value1', PARAM_INT); + $mform->setDefault($this->name . '_value1', 0); + + $objs['text2'] = $mform->createElement('text', $this->name . '_value2', null, ['size' => 3]); + $mform->setType($this->name . '_value2', PARAM_INT); + $mform->setDefault($this->name . '_value2', 0); + + $mform->addElement('group', $this->name . '_grp', '', $objs, '', false); + + $mform->hideIf($this->name . '_value1', $this->name . '_operator', 'eq', self::ANY_VALUE); + $mform->hideIf($this->name . '_value1', $this->name . '_operator', 'eq', self::IS_NOT_EMPTY); + $mform->hideIf($this->name . '_value1', $this->name . '_operator', 'eq', self::IS_EMPTY); + + $mform->hideIf($this->name . '_value2', $this->name . '_operator', 'noteq', self::RANGE); + } + + /** + * Return filter SQL + * + * @param array $values + * @return array array of two elements - SQL query and named parameters + */ + public function get_sql_filter(array $values) : array { + $operator = $values["{$this->name}_operator"] ?? self::ANY_VALUE; + $value1 = $values["{$this->name}_value1"] ?? null; + $value2 = $values["{$this->name}_value2"] ?? null; + + // Validate filter form values. + if (!$this->validate_filter_values($operator, $value1, $value2)) { + // Filter configuration is invalid. Ignore the filter. + return ['', []]; + } + + $param = database::generate_param_name(); + $param2 = database::generate_param_name(); + $fieldsql = $this->filter->get_field_sql(); + $params = $this->filter->get_field_params(); + + switch ($operator) { + case self::ANY_VALUE: + return ['', []]; + case self::IS_NOT_EMPTY: + $res = "{$fieldsql} IS NOT NULL AND {$fieldsql} <> 0"; + break; + case self::IS_EMPTY: + $res = "{$fieldsql} IS NULL OR {$fieldsql} = 0"; + break; + case self::LESS_THAN: + $res = "{$fieldsql} < :{$param}"; + $params[$param] = $value1; + break; + case self::GREATER_THAN: + $res = "{$fieldsql} > :{$param}"; + $params[$param] = $value1; + break; + case self::EQUAL_TO: + $res = "{$fieldsql} = :{$param}"; + $params[$param] = $value1; + break; + case self::EQUAL_OR_LESS_THAN: + $res = "{$fieldsql} <= :{$param}"; + $params[$param] = $value1; + break; + case self::EQUAL_OR_GREATER_THAN: + $res = "{$fieldsql} >= :{$param}"; + $params[$param] = $value1; + break; + case self::RANGE: + $res = "({$fieldsql} >= :{$param} AND {$fieldsql} <= :{$param2})"; + $params[$param] = $value1; + $params[$param2] = $value2; + break; + default: + // Filter configuration is invalid. Ignore the filter. + return ['', []]; + } + return [$res, $params]; + } + + /** + * Validate filter form values + * + * @param int $operator + * @param int|null $value1 + * @param int|null $value2 + * @return bool + */ + private function validate_filter_values(int $operator, ?int $value1, ?int $value2): bool { + // Check that for any of these operators value1 can not be null. + $requirescomparisonvalue = [ + self::LESS_THAN, + self::GREATER_THAN, + self::EQUAL_TO, + self::EQUAL_OR_LESS_THAN, + self::EQUAL_OR_GREATER_THAN + ]; + if (in_array($operator, $requirescomparisonvalue) && $value1 === null) { + return false; + } + + // When operator is between $value1 and $value2, can not be null. + if (($operator === self::RANGE) && ($value1 === null || $value2 === null)) { + return false; + } + + return true; + } +} diff --git a/reportbuilder/classes/local/filters/select.php b/reportbuilder/classes/local/filters/select.php new file mode 100644 index 00000000000..1ff6cab0cfe --- /dev/null +++ b/reportbuilder/classes/local/filters/select.php @@ -0,0 +1,134 @@ +. + +declare(strict_types=1); + +namespace core_reportbuilder\local\filters; + +use MoodleQuickForm; +use core_reportbuilder\local\helpers\database; + +/** + * Select report filter + * + * @package core_reportbuilder + * @copyright 2021 David Matamoros + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class select extends base { + + /** @var int Any value */ + public const ANY_VALUE = 0; + + /** @var int Equal to */ + public const EQUAL_TO = 1; + + /** @var int Not equal to */ + public const NOT_EQUAL_TO = 2; + + /** + * Returns an array of comparison operators + * + * @return array + */ + private function get_operators(): array { + $operators = [ + self::ANY_VALUE => get_string('filterisanyvalue', 'core_reportbuilder'), + self::EQUAL_TO => get_string('filterisequalto', 'core_reportbuilder'), + self::NOT_EQUAL_TO => get_string('filterisnotequalto', 'core_reportbuilder') + ]; + + return $this->filter->restrict_limited_operators($operators); + } + + /** + * Return the options for the filter as an array, to be used to populate the select input field. These options should be + * specified when creating the filter via the {@see set_options} or {@see set_options_callback} method + * + * @return array + */ + private function get_select_options(): array { + return (array) $this->filter->get_options(); + } + + /** + * Adds controls specific to this filter in the form. + * + * @param MoodleQuickForm $mform + */ + public function setup_form(MoodleQuickForm $mform): void { + $elements = []; + $elements['operator'] = $mform->createElement('select', $this->name . '_operator', null, $this->get_operators()); + + // If a multi-dimensional array is passed, we need to use a different element type. + $options = $this->get_select_options(); + $element = (count($options) == count($options, COUNT_RECURSIVE) ? 'select' : 'selectgroups'); + $elements['value'] = $mform->createElement($element, $this->name . '_value', null, $options); + + $mform->addElement('group', $this->name . '_group', '', $elements, '', false); + + $mform->hideIf($this->name . '_value', $this->name . '_operator', 'eq', self::ANY_VALUE); + } + + /** + * Return filter SQL + * + * Note that operators must be of type integer, while values can be integer or string. + * + * @param array $values + * @return array array of two elements - SQL query and named parameters + */ + public function get_sql_filter(array $values): array { + $name = database::generate_param_name(); + + $operator = $values["{$this->name}_operator"] ?? self::ANY_VALUE; + $value = $values["{$this->name}_value"] ?? 0; + + $fieldsql = $this->filter->get_field_sql(); + $params = $this->filter->get_field_params(); + + // Validate filter form values. + if (!$this->validate_filter_values((int) $operator, $value)) { + // Filter configuration is invalid. Ignore the filter. + return ['', []]; + } + + switch ($operator) { + case self::EQUAL_TO: + $fieldsql .= "=:$name"; + $params[$name] = $value; + break; + case self::NOT_EQUAL_TO: + $fieldsql .= "<>:$name"; + $params[$name] = $value; + break; + default: + return ['', []]; + } + return [$fieldsql, $params]; + } + + /** + * Validate filter form values + * + * @param int|null $operator + * @param mixed|null $value + * @return bool + */ + private function validate_filter_values(?int $operator, $value): bool { + return !($operator === null || $value === ''); + } +} diff --git a/reportbuilder/classes/local/filters/text.php b/reportbuilder/classes/local/filters/text.php new file mode 100644 index 00000000000..b00db726625 --- /dev/null +++ b/reportbuilder/classes/local/filters/text.php @@ -0,0 +1,193 @@ +. + +declare(strict_types=1); + +namespace core_reportbuilder\local\filters; + +use core_reportbuilder\local\helpers\database; + +/** + * Text report filter + * + * @package core_reportbuilder + * @copyright 2021 David Matamoros + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class text extends base { + + /** @var int */ + public const ANY_VALUE = 0; + + /** @var int */ + public const CONTAINS = 1; + + /** @var int */ + public const DOES_NOT_CONTAIN = 2; + + /** @var int */ + public const IS_EQUAL_TO = 3; + + /** @var int */ + public const IS_NOT_EQUAL_TO = 4; + + /** @var int */ + public const STARTS_WITH = 5; + + /** @var int */ + public const ENDS_WITH = 6; + + /** @var int */ + public const IS_EMPTY = 7; + + /** @var int */ + public const IS_NOT_EMPTY = 8; + + /** + * Return an array of operators available for this filter + * + * @return array of comparison operators + */ + private function get_operators() : array { + $operators = [ + self::ANY_VALUE => get_string('filterisanyvalue', 'core_reportbuilder'), + self::CONTAINS => get_string('filtercontains', 'core_reportbuilder'), + self::DOES_NOT_CONTAIN => get_string('filterdoesnotcontain', 'core_reportbuilder'), + self::IS_EQUAL_TO => get_string('filterisequalto', 'core_reportbuilder'), + self::IS_NOT_EQUAL_TO => get_string('filterisnotequalto', 'core_reportbuilder'), + self::STARTS_WITH => get_string('filterstartswith', 'core_reportbuilder'), + self::ENDS_WITH => get_string('filterendswith', 'core_reportbuilder'), + self::IS_EMPTY => get_string('filterisempty', 'core_reportbuilder'), + self::IS_NOT_EMPTY => get_string('filterisnotempty', 'core_reportbuilder') + ]; + + return $this->filter->restrict_limited_operators($operators); + } + + /** + * Adds controls specific to this filter in the form. + * + * Operator selector use the "$this->name . '_operator'" naming convention and the fields to enter custom values should + * use "$this->name . '_value'" or _value1/_value2/... in case there is more than one field for their naming. + * + * @param \MoodleQuickForm $mform + */ + public function setup_form(\MoodleQuickForm $mform): void { + $elements = []; + $elements['operator'] = $mform->createElement('select', $this->name . '_operator', + get_string('filterfieldoperator', 'core_reportbuilder', $this->get_header()), $this->get_operators()); + $elements['value'] = $mform->createElement('text', $this->name . '_value', + get_string('filterfieldvalue', 'core_reportbuilder', $this->get_header())); + + $mform->addElement('group', $this->name . '_group', '', $elements, '', false); + + $mform->setType($this->name . '_value', PARAM_RAW); + $mform->hideIf($this->name . '_value', $this->name . '_operator', 'eq', self::ANY_VALUE); + $mform->hideIf($this->name . '_value', $this->name . '_operator', 'eq', self::IS_EMPTY); + $mform->hideIf($this->name . '_value', $this->name . '_operator', 'eq', self::IS_NOT_EMPTY); + } + + /** + * Return filter SQL + * + * @param array|null $values + * @return array array of two elements - SQL query and named parameters + */ + public function get_sql_filter(?array $values) : array { + global $DB; + $name = database::generate_param_name(); + + if (!$values) { + return ['', []]; + } + + $operator = (int) ($values["{$this->name}_operator"] ?? self::ANY_VALUE); + $value = $values["{$this->name}_value"] ?? ''; + + $fieldsql = $this->filter->get_field_sql(); + $params = $this->filter->get_field_params(); + + // Validate filter form values. + if (!$this->validate_filter_values($operator, $value)) { + // Filter configuration is invalid. Ignore the filter. + return ['', []]; + } + + switch($operator) { + case self::CONTAINS: + $res = $DB->sql_like($fieldsql, ":$name", false, false); + $value = $DB->sql_like_escape($value); + $params[$name] = "%$value%"; + break; + case self::DOES_NOT_CONTAIN: + $res = $DB->sql_like($fieldsql, ":$name", false, false, true); + $value = $DB->sql_like_escape($value); + $params[$name] = "%$value%"; + break; + case self::IS_EQUAL_TO: + $res = $DB->sql_equal($fieldsql, ":$name", false, false); + $params[$name] = $value; + break; + case self::IS_NOT_EQUAL_TO: + $res = $DB->sql_equal($fieldsql, ":$name", false, false, true); + $params[$name] = $value; + break; + case self::STARTS_WITH: + $res = $DB->sql_like($fieldsql, ":$name", false, false); + $value = $DB->sql_like_escape($value); + $params[$name] = "$value%"; + break; + case self::ENDS_WITH: + $res = $DB->sql_like($fieldsql, ":$name", false, false); + $value = $DB->sql_like_escape($value); + $params[$name] = "%$value"; + break; + case self::IS_EMPTY: // Note we also account for field not existing here. + $res = "COALESCE({$fieldsql}, '') = :{$name}"; + $params[$name] = ''; + break; + case self::IS_NOT_EMPTY: + $res = "COALESCE({$fieldsql}, '') != :{$name}"; + $params[$name] = ''; + break; + default: + // Filter configuration is invalid. Ignore the filter. + return ['', []]; + } + return array($res, $params); + } + + /** + * Validate filter form values + * + * @param int $operator + * @param string|null $value + * @return bool + */ + private function validate_filter_values(int $operator, ?string $value): bool { + $operatorsthatdontrequirevalue = [ + self::ANY_VALUE, + self::IS_EMPTY, + self::IS_NOT_EMPTY, + ]; + + if ($value === '' && !in_array($operator, $operatorsthatdontrequirevalue)) { + return false; + } + + return true; + } +} diff --git a/reportbuilder/classes/local/helpers/user_filter_manager.php b/reportbuilder/classes/local/helpers/user_filter_manager.php new file mode 100644 index 00000000000..0bf19202498 --- /dev/null +++ b/reportbuilder/classes/local/helpers/user_filter_manager.php @@ -0,0 +1,175 @@ +. + +declare(strict_types=1); + +namespace core_reportbuilder\local\helpers; + +use core_text; + +/** + * This class handles the setting and retrieving of a users' filter values for given reports + * + * It is currently using the user preference API as a storage mechanism + * + * @package core_reportbuilder + * @copyright 2021 Paul Holden + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class user_filter_manager { + + /** @var int The size of each chunk, matching the maximum length of a single user preference */ + private const PREFERENCE_CHUNK_SIZE = 1333; + + /** @var string The prefix used to name the stored user preferences */ + private const PREFERENCE_NAME_PREFIX = 'reportbuilder-report-'; + + /** + * Generate user preference name for given report + * + * @param int $reportid + * @param int $index + * @return string + */ + private static function user_preference_name(int $reportid, int $index): string { + return static::PREFERENCE_NAME_PREFIX . "{$reportid}-{$index}"; + } + + /** + * Set user filters for given report + * + * @param int $reportid + * @param array $values + * @param int|null $userid + * @return bool + */ + public static function set(int $reportid, array $values, int $userid = null): bool { + $jsonvalues = json_encode($values); + + $jsonchunks = str_split($jsonvalues, static::PREFERENCE_CHUNK_SIZE); + foreach ($jsonchunks as $index => $jsonchunk) { + $userpreference = static::user_preference_name($reportid, $index); + set_user_preference($userpreference, $jsonchunk, $userid); + } + + // Ensure any subsequent preferences are reset (to account for number of chunks decreasing). + static::reset_all($reportid, $userid, $index + 1); + + return true; + } + + /** + * Get user filters for given report + * + * @param int $reportid + * @param int|null $userid + * @return array + */ + public static function get(int $reportid, int $userid = null): array { + $jsonvalues = ''; + $index = 0; + + // We'll repeatedly append chunks to our JSON string, until we hit one that is below the maximum length. + do { + $userpreference = static::user_preference_name($reportid, $index++); + $jsonchunk = get_user_preferences($userpreference, '', $userid); + $jsonvalues .= $jsonchunk; + } while (core_text::strlen($jsonchunk) === static::PREFERENCE_CHUNK_SIZE); + + return (array) json_decode($jsonvalues); + } + + /** + * Merge individual user filter values for given report + * + * @param int $reportid + * @param array $values + * @param int|null $userid + * @return bool + */ + public static function merge(int $reportid, array $values, int $userid = null): bool { + $existing = static::get($reportid, $userid); + + return static::set($reportid, array_merge($existing, $values), $userid); + } + + /** + * Reset all user filters for given report + * + * @param int $reportid + * @param int|null $userid + * @param int $index If specified, then preferences will be reset starting from this index + * @return bool + */ + public static function reset_all(int $reportid, int $userid = null, int $index = 0): bool { + // We'll repeatedly retrieve and reset preferences, until we hit one that is below the maximum length. + do { + $userpreference = static::user_preference_name($reportid, $index++); + $jsonchunk = get_user_preferences($userpreference, '', $userid); + unset_user_preference($userpreference, $userid); + } while (core_text::strlen($jsonchunk) === static::PREFERENCE_CHUNK_SIZE); + + return true; + } + + /** + * Reset single user filter for given report + * + * @param int $reportid + * @param string $uniqueidentifier + * @param int|null $userid + * @return bool + */ + public static function reset_single(int $reportid, string $uniqueidentifier, int $userid = null): bool { + $originalvalues = static::get($reportid, $userid); + + // Remove any filters whose name is prefixed by given identifier. + $values = array_filter($originalvalues, static function(string $filterkey) use ($uniqueidentifier): bool { + return core_text::strpos($filterkey, $uniqueidentifier) !== 0; + }, ARRAY_FILTER_USE_KEY); + + return static::set($reportid, $values, $userid); + } + + /** + * Get all report filters for given user + * + * This is primarily designed for the privacy provider, and allows us to preserve all the preference logic within this class. + * + * @param int $userid + * @return array + */ + public static function get_all_for_user(int $userid): array { + global $DB; + $prefs = []; + + // We need to locate the first preference chunk of all report filters. + $select = 'userid = :userid AND ' . $DB->sql_like('name', ':namelike'); + $params = [ + 'userid' => $userid, + 'namelike' => $DB->sql_like_escape(static::PREFERENCE_NAME_PREFIX) . '%-0', + ]; + $preferences = $DB->get_fieldset_select('user_preferences', 'name', $select, $params); + + // Retrieve all found filters. + foreach ($preferences as $preference) { + preg_match('/^' . static::PREFERENCE_NAME_PREFIX . '(?\d+)\-/', $preference, $matches); + $prefs[static::PREFERENCE_NAME_PREFIX . $matches['reportid']] = static::get((int) $matches['reportid'], $userid); + } + + return $prefs; + } +} diff --git a/reportbuilder/classes/local/report/filter.php b/reportbuilder/classes/local/report/filter.php new file mode 100644 index 00000000000..aa7757b96af --- /dev/null +++ b/reportbuilder/classes/local/report/filter.php @@ -0,0 +1,310 @@ +. + +declare(strict_types=1); + +namespace core_reportbuilder\local\report; + +use lang_string; +use moodle_exception; +use core_reportbuilder\local\filters\base; + +/** + * Class to represent a report filter + * + * @package core_reportbuilder + * @copyright 2021 Paul Holden + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +final class filter { + + /** @var string $filterclass */ + private $filterclass; + + /** @var string $name */ + private $name; + + /** @var lang_string $header */ + private $header; + + /** @var string $entity */ + private $entityname; + + /** @var string $fieldsql */ + private $fieldsql = ''; + + /** @var array $fieldparams */ + private $fieldparams = []; + + /** @var string[] $joins */ + protected $joins = []; + + /** @var bool $available */ + protected $available = true; + + /** @var mixed $options */ + protected $options; + + /** @var array $limitoperators */ + protected $limitoperators = []; + + /** + * Filter constructor + * + * @param string $filterclass Filter type class to use, must extend {@see base} filter class + * @param string $name Internal name of the filter + * @param lang_string $header Title of the filter used in reports + * @param string $entityname Name of the entity this filter belongs to. Typically when creating filters within entities + * this value should be the result of calling {@see get_entity_name}, however if creating filters inside reports directly + * it should be the name of the entity as passed to {@see \core_reportbuilder\local\report\base::annotate_entity} + * @param string $fieldsql SQL clause to use for filtering, {@see set_field_sql} + * @param array $fieldparams + * @throws moodle_exception For invalid filter class + */ + public function __construct( + string $filterclass, + string $name, + lang_string $header, + string $entityname, + string $fieldsql = '', + array $fieldparams = [] + ) { + if (!class_exists($filterclass) || !is_subclass_of($filterclass, base::class)) { + throw new moodle_exception('filterinvalid', 'reportbuilder', '', null, $filterclass); + } + + $this->filterclass = $filterclass; + $this->name = $name; + $this->header = $header; + $this->entityname = $entityname; + + if ($fieldsql !== '') { + $this->set_field_sql($fieldsql, $fieldparams); + } + } + + /** + * Get filter class path + * + * @return string + */ + public function get_filter_class(): string { + return $this->filterclass; + } + + /** + * Get filter name + * + * @return string + */ + public function get_name(): string { + return $this->name; + } + + /** + * Return header + * + * @return string + */ + public function get_header(): string { + return $this->header->out(); + } + + /** + * Set header + * + * @param lang_string $header + * @return self + */ + public function set_header(lang_string $header): self { + $this->header = $header; + return $this; + } + + /** + * Return filter entity name + * + * @return string + */ + public function get_entity_name(): string { + return $this->entityname; + } + + /** + * Return unique identifier for this filter + * + * @return string + */ + public function get_unique_identifier(): string { + return $this->get_entity_name() . ':' . $this->get_name(); + } + + /** + * Return joins + * + * @return string[] + */ + public function get_joins(): array { + return array_values($this->joins); + } + + /** + * Add join clause required for this filter to join to existing tables/entities + * + * This is necessary in the case where {@see set_field_sql} is selecting data from a table that isn't otherwise queried + * + * @param string $join + * @return self + */ + public function add_join(string $join): self { + $this->joins[trim($join)] = trim($join); + return $this; + } + + /** + * Add multiple join clauses required for this filter, passing each to {@see add_join} + * + * Typically when defining filters in entities, you should pass {@see \core_reportbuilder\local\report\base::get_joins} to + * this method, so that all entity joins are included in the report when your filter is used in it + * + * @param string[] $joins + * @return self + */ + public function add_joins(array $joins): self { + foreach ($joins as $join) { + $this->add_join($join); + } + return $this; + } + + /** + * Get SQL expression for the field + * + * @return string + */ + public function get_field_sql(): string { + return $this->fieldsql; + } + + /** + * Get the SQL params for the field being filtered + * + * @return array + */ + public function get_field_params(): array { + return $this->fieldparams; + } + + /** + * Set the SQL expression for the field that is being filtered. It will be passed to the filter class + * + * @param string $sql + * @param array $params + * @return self + */ + public function set_field_sql(string $sql, array $params = []): self { + $this->fieldsql = $sql; + $this->fieldparams = $params; + return $this; + } + + /** + * Return available state of the filter for the current user + * + * @return bool + */ + public function get_is_available(): bool { + return $this->available; + } + + /** + * Conditionally set whether the filter is available. For instance the filter may be added to a report with the + * expectation that only some users are able to see it + * + * @param bool $available + * @return self + */ + public function set_is_available(bool $available): self { + $this->available = $available; + return $this; + } + + /** + * Set the options for the filter in the format that the filter class expected (e.g. the "select" filter expects an array) + * + * This method should only be used if the options do not require any calculations/queries, in which + * case {@see set_options_callback} should be used. For performance, {@see get_string} shouldn't be used either, use of + * {@see lang_string} is instead encouraged + * + * @param mixed $options + * @return self + */ + public function set_options($options): self { + $this->options = $options; + return $this; + } + + /** + * Set the options for the filter to be returned by a callback (that recieves no arguments) in the format that the filter + * class expects + * + * @param callable $callback + * @return self + */ + public function set_options_callback(callable $callback): self { + $this->options = $callback; + return $this; + } + + /** + * Get the options for the filter, returning via the the previously set options or generated via defined options callback + * + * @return mixed + */ + public function get_options() { + if (is_callable($this->options)) { + $callable = $this->options; + $this->options = ($callable)(); + } + return $this->options; + } + + /** + * Set a limited subset of operators that should be used for the filter, refer to each filter class to find defined + * operator constants + * + * @param array $limitoperators Simple array of operator values + * @return self + */ + public function set_limited_operators(array $limitoperators): self { + $this->limitoperators = $limitoperators; + return $this; + } + + /** + * Filter given operators to include only those previously defined by {@see set_limited_operators} + * + * @param array $operators All operators as defined by the filter class + * @return array + */ + public function restrict_limited_operators(array $operators): array { + if (empty($this->limitoperators)) { + return $operators; + } + + return array_intersect_key($operators, array_flip($this->limitoperators)); + } +} diff --git a/reportbuilder/tests/external/filters/reset_test.php b/reportbuilder/tests/external/filters/reset_test.php new file mode 100644 index 00000000000..9f5665bdd35 --- /dev/null +++ b/reportbuilder/tests/external/filters/reset_test.php @@ -0,0 +1,61 @@ +. + +declare(strict_types=1); + +namespace core_reportbuilder\external\filters; + +use external_api; +use externallib_advanced_testcase; +use core_reportbuilder\local\helpers\user_filter_manager; + +defined('MOODLE_INTERNAL') || die(); + +global $CFG; +require_once("{$CFG->dirroot}/webservice/tests/helpers.php"); + +/** + * Unit tests external filters reset class + * + * @package core_reportbuilder + * @covers \core_reportbuilder\external\filters\reset + * @copyright 2021 Paul Holden + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class reset_testcase extends externallib_advanced_testcase { + + /** + * Text execute method + */ + public function test_execute(): void { + $this->resetAfterTest(); + $this->setAdminUser(); + + user_filter_manager::set(5, [ + 'entity:filter_name' => 'something', + ]); + + $this->assertCount(1, user_filter_manager::get(5)); + + $result = reset::execute(5); + $result = external_api::clean_returnvalue(reset::execute_returns(), $result); + + $this->assertTrue($result); + + // We should get an empty array back. + $this->assertEquals([], user_filter_manager::get(5)); + } +} diff --git a/reportbuilder/tests/local/filters/boolean_select_test.php b/reportbuilder/tests/local/filters/boolean_select_test.php new file mode 100644 index 00000000000..55f63deb883 --- /dev/null +++ b/reportbuilder/tests/local/filters/boolean_select_test.php @@ -0,0 +1,86 @@ +. + +declare(strict_types=1); + +namespace core_reportbuilder\local\filters; + +use advanced_testcase; +use lang_string; +use core_reportbuilder\local\report\filter; + +/** + * Unit tests for boolean report filter + * + * @package core_reportbuilder + * @covers \core_reportbuilder\local\filters\base + * @covers \core_reportbuilder\local\filters\boolean_select + * @copyright 2021 Paul Holden + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class boolean_select_testcase extends advanced_testcase { + + /** + * Data provider for {@see test_get_sql_filter_simple} + * + * @return array + */ + public function get_sql_filter_simple_provider(): array { + return [ + [boolean_select::ANY_VALUE, true], + [boolean_select::CHECKED, true], + [boolean_select::NOT_CHECKED, false], + ]; + } + + /** + * Test getting filter SQL + * + * @param int $operator + * @param bool $expectuser + * + * @dataProvider get_sql_filter_simple_provider + */ + public function test_get_sql_filter_simple(int $operator, bool $expectuser): void { + global $DB; + + $this->resetAfterTest(); + + $user = $this->getDataGenerator()->create_user([ + 'suspended' => 1, + ]); + + $filter = new filter( + boolean_select::class, + 'test', + new lang_string('yes'), + 'testentity', + 'suspended' + ); + + // Create instance of our filter, passing given operator. + [$select, $params] = boolean_select::create($filter)->get_sql_filter([ + $filter->get_unique_identifier() . '_operator' => $operator, + ]); + + $usernames = $DB->get_fieldset_select('user', 'username', $select, $params); + if ($expectuser) { + $this->assertContains($user->username, $usernames); + } else { + $this->assertNotContains($user->username, $usernames); + } + } +} diff --git a/reportbuilder/tests/local/filters/course_selector_test.php b/reportbuilder/tests/local/filters/course_selector_test.php new file mode 100644 index 00000000000..155fc98454a --- /dev/null +++ b/reportbuilder/tests/local/filters/course_selector_test.php @@ -0,0 +1,77 @@ +. + +declare(strict_types=1); + +namespace core_reportbuilder\local\filters; + +use advanced_testcase; +use core_reportbuilder\local\report\filter; + +/** + * Unit tests for course selector filter + * + * @package core_reportbuilder + * @covers \core_reportbuilder\local\filters\base + * @covers \core_reportbuilder\local\filters\course_selector + * @copyright 2021 David Matamoros + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class course_selector_testcase extends advanced_testcase { + + /** + * Test getting filter SQL + */ + public function test_get_sql_filter(): void { + global $DB; + + $this->resetAfterTest(); + + $course1 = $this->getDataGenerator()->create_course([ + 'fullname' => "Time travel", + ]); + + $course2 = $this->getDataGenerator()->create_course([ + 'fullname' => "Quantum computing", + ]); + + $course3 = $this->getDataGenerator()->create_course([ + 'fullname' => "Space travel", + ]); + + $filter = new filter( + course_selector::class, + 'test', + new \lang_string('course'), + 'testentity', + 'id' + ); + + // Create instance of our filter, passing given courses ids. + [$select, $params] = text::create($filter)->get_sql_filter([ + $filter->get_unique_identifier() . '_values' => [$course1->id, $course3->id], + ]); + $fullnames = $DB->get_fieldset_select('course', 'fullname', $select, $params); + $this->assertEqualsCanonicalizing(['Time travel', 'Space travel'], $fullnames); + + // Test without passing any course id. + [$select, $params] = text::create($filter)->get_sql_filter([ + $filter->get_unique_identifier() . '_values' => [], + ]); + $fullnames = $DB->get_fieldset_select('course', 'fullname', $select, $params); + $this->assertEqualsCanonicalizing(['Time travel', 'Quantum computing', 'Space travel', 'PHPUnit test site'], $fullnames); + } +} diff --git a/reportbuilder/tests/local/filters/date_test.php b/reportbuilder/tests/local/filters/date_test.php new file mode 100644 index 00000000000..e9073a4f72d --- /dev/null +++ b/reportbuilder/tests/local/filters/date_test.php @@ -0,0 +1,117 @@ +. + +declare(strict_types=1); + +namespace core_reportbuilder\local\filters; + +use advanced_testcase; +use lang_string; +use core_reportbuilder\local\report\filter; + +/** + * Unit tests for date report filter + * + * @package core_reportbuilder + * @covers \core_reportbuilder\local\filters\base + * @covers \core_reportbuilder\local\filters\date + * @copyright 2021 Paul Holden + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class date_testcase extends advanced_testcase { + + /** + * Data provider for {@see test_get_sql_filter_simple} + * + * @return array + */ + public function get_sql_filter_simple_provider(): array { + return [ + [date::DATE_ANY, true], + [date::DATE_NOT_EMPTY, true], + [date::DATE_EMPTY, false], + ]; + } + + /** + * Test getting filter SQL + * + * @param int $operator + * @param bool $expectuser + * + * @dataProvider get_sql_filter_simple_provider + */ + public function test_get_sql_filter_simple(int $operator, bool $expectuser): void { + global $DB; + + $this->resetAfterTest(); + + $user = $this->getDataGenerator()->create_user([ + 'timecreated' => 12345, + ]); + + $filter = new filter( + date::class, + 'test', + new lang_string('yes'), + 'testentity', + 'timecreated' + ); + + // Create instance of our filter, passing given operator. + [$select, $params] = date::create($filter)->get_sql_filter([ + $filter->get_unique_identifier() . '_operator' => $operator, + ]); + + $usernames = $DB->get_fieldset_select('user', 'username', $select, $params); + if ($expectuser) { + $this->assertContains($user->username, $usernames); + } else { + $this->assertNotContains($user->username, $usernames); + } + } + + /** + * Test getting filter SQL while specifying a date range + */ + public function test_get_sql_filter_date_range(): void { + global $DB; + + $this->resetAfterTest(); + + $userone = $this->getDataGenerator()->create_user(['timecreated' => 50]); + $usertwo = $this->getDataGenerator()->create_user(['timecreated' => 100]); + + $filter = new filter( + date::class, + 'test', + new lang_string('yes'), + 'testentity', + 'timecreated' + ); + + // Create instance of our date range filter. + [$select, $params] = date::create($filter)->get_sql_filter([ + $filter->get_unique_identifier() . '_operator' => date::DATE_RANGE, + $filter->get_unique_identifier() . '_from' => 80, + $filter->get_unique_identifier() . '_to' => 120, + ]); + + // The only matching user should be our first test user. + $usernames = $DB->get_fieldset_select('user', 'username', $select, $params); + $this->assertEquals([$usertwo->username], $usernames); + } +} diff --git a/reportbuilder/tests/local/filters/number_test.php b/reportbuilder/tests/local/filters/number_test.php new file mode 100644 index 00000000000..eb8b33dbedc --- /dev/null +++ b/reportbuilder/tests/local/filters/number_test.php @@ -0,0 +1,148 @@ +. + +declare(strict_types=1); + +namespace core_reportbuilder\local\filters; + +use advanced_testcase; +use lang_string; +use core_reportbuilder\local\report\filter; + +/** + * Unit tests for number report filter + * + * @package core_reportbuilder + * @covers \core_reportbuilder\local\filters\base + * @covers \core_reportbuilder\local\filters\number + * @copyright 2021 David Matamoros + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class number_testcase extends advanced_testcase { + + /** + * Data provider for {@see test_get_sql_filter_simple} + * + * @return array[] + */ + public function get_sql_filter_simple_provider(): array { + return [ + [number::ANY_VALUE, null, null, true], + [number::IS_NOT_EMPTY, null, null, true], + [number::IS_EMPTY, null, null, false], + [number::LESS_THAN, 1, null, false], + [number::LESS_THAN, 123, null, false], + [number::LESS_THAN, 124, null, true], + [number::GREATER_THAN, 1, null, true], + [number::GREATER_THAN, 123, null, false], + [number::GREATER_THAN, 124, null, false], + [number::EQUAL_TO, 123, null, true], + [number::EQUAL_TO, 124, null, false], + [number::EQUAL_OR_LESS_THAN, 124, null, true], + [number::EQUAL_OR_LESS_THAN, 123, null, true], + [number::EQUAL_OR_LESS_THAN, 122, null, false], + [number::EQUAL_OR_GREATER_THAN, 122, null, true], + [number::EQUAL_OR_GREATER_THAN, 123, null, true], + [number::EQUAL_OR_GREATER_THAN, 124, null, false], + [number::RANGE, 122, 124, true], + [number::RANGE, 124, 125, false], + [number::RANGE, 122, 123, true], + [number::RANGE, 123, 124, true], + ]; + } + + /** + * Test getting filter SQL + * + * @param int $operator + * @param int|null $value1 + * @param int|null $value2 + * @param bool $expectmatch + * + * @dataProvider get_sql_filter_simple_provider + */ + public function test_get_sql_filter_simple(int $operator, ?int $value1, ?int $value2, bool $expectmatch): void { + global $DB; + + $this->resetAfterTest(); + + $course = $this->getDataGenerator()->create_course([ + 'timecreated' => 123, + ]); + + $filter = new filter( + number::class, + 'test', + new lang_string('course'), + 'testentity', + 'timecreated' + ); + + // Create instance of our filter, passing given operator. + [$select, $params] = number::create($filter)->get_sql_filter([ + $filter->get_unique_identifier() . '_value1' => $value1, + $filter->get_unique_identifier() . '_value2' => $value2, + $filter->get_unique_identifier() . '_operator' => $operator, + ]); + + $fullnames = $DB->get_fieldset_select('course', 'fullname', $select, $params); + if ($expectmatch) { + $this->assertContains($course->fullname, $fullnames); + } else { + $this->assertNotContains($course->fullname, $fullnames); + } + } + + /** + * Data provider for {@see test_get_sql_filter_invalid} + * + * @return array[] + */ + public function get_sql_filter_invalid_provider(): array { + return [ + [number::LESS_THAN], + [number::GREATER_THAN], + [number::EQUAL_TO], + [number::EQUAL_OR_LESS_THAN], + [number::EQUAL_OR_GREATER_THAN], + [number::RANGE], + ]; + } + + /** + * Test getting filter SQL for operators that require values + * + * @param int $operator + * + * @dataProvider get_sql_filter_invalid_provider + */ + public function test_get_sql_filter_invalid(int $operator): void { + $filter = new filter( + number::class, + 'test', + new lang_string('course'), + 'testentity', + 'timecreated' + ); + + [$select, $params] = number::create($filter)->get_sql_filter([ + $filter->get_unique_identifier() . '_operator' => $operator, + ]); + + $this->assertEquals('', $select); + $this->assertEquals([], $params); + } +} diff --git a/reportbuilder/tests/local/filters/select_test.php b/reportbuilder/tests/local/filters/select_test.php new file mode 100644 index 00000000000..f0fe6567be6 --- /dev/null +++ b/reportbuilder/tests/local/filters/select_test.php @@ -0,0 +1,98 @@ +. + +declare(strict_types=1); + +namespace core_reportbuilder\local\filters; + +use advanced_testcase; +use lang_string; +use core_reportbuilder\local\report\filter; + +/** + * Unit tests for select report filter + * + * @package core_reportbuilder + * @covers \core_reportbuilder\local\filters\base + * @covers \core_reportbuilder\local\filters\select + * @copyright 2021 David Matamoros + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class select_testcase extends advanced_testcase { + + /** + * Data provider for {@see test_get_sql_filter_simple} + * + * @return array + */ + public function get_sql_filter_simple_provider(): array { + return [ + [select::ANY_VALUE, null, true], + [select::EQUAL_TO, 'starwars', true], + [select::EQUAL_TO, 'mandalorian', false], + [select::NOT_EQUAL_TO, 'starwars', false], + [select::NOT_EQUAL_TO, 'mandalorian', true], + ]; + } + + /** + * Test getting filter SQL + * + * @param int $operator + * @param string|null $value + * @param bool $expectmatch + * + * @dataProvider get_sql_filter_simple_provider + */ + public function test_get_sql_filter_simple(int $operator, ?string $value, bool $expectmatch): void { + global $DB; + + $this->resetAfterTest(); + + $course1 = $this->getDataGenerator()->create_course([ + 'fullname' => "May the course be with you", + 'shortname' => 'starwars', + ]); + $course2 = $this->getDataGenerator()->create_course([ + 'fullname' => "This is the course", + 'shortname' => 'mandalorian', + ]); + + $filter = (new filter( + select::class, + 'test', + new lang_string('course'), + 'testentity', + 'shortname' + ))->set_options([ + $course1->shortname => $course1->fullname, + $course2->shortname => $course2->fullname, + ]); + + // Create instance of our filter, passing given operator. + [$select, $params] = select::create($filter)->get_sql_filter([ + $filter->get_unique_identifier() . '_operator' => $operator, + $filter->get_unique_identifier() . '_value' => $value, + ]); + + $fullnames = $DB->get_fieldset_select('course', 'fullname', $select, $params); + if ($expectmatch) { + $this->assertContains($course1->fullname, $fullnames); + } else { + $this->assertNotContains($course1->fullname, $fullnames); + } + } +} diff --git a/reportbuilder/tests/local/filters/text_test.php b/reportbuilder/tests/local/filters/text_test.php new file mode 100644 index 00000000000..f16b4e0501a --- /dev/null +++ b/reportbuilder/tests/local/filters/text_test.php @@ -0,0 +1,100 @@ +. + +declare(strict_types=1); + +namespace core_reportbuilder\local\filters; + +use advanced_testcase; +use lang_string; +use core_reportbuilder\local\report\filter; + +/** + * Unit tests for text report filter + * + * @package core_reportbuilder + * @covers \core_reportbuilder\local\filters\base + * @covers \core_reportbuilder\local\filters\text + * @copyright 2021 David Matamoros + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class text_testcase extends advanced_testcase { + + /** + * Data provider for {@see test_get_sql_filter_simple} + * + * @return array + */ + public function get_sql_filter_simple_provider(): array { + return [ + [text::ANY_VALUE, null, true], + [text::CONTAINS, 'looking', true], + [text::CONTAINS, 'sky', false], + [text::DOES_NOT_CONTAIN, 'sky', true], + [text::DOES_NOT_CONTAIN, 'looking', false], + [text::IS_EQUAL_TO, "Hello, is it me you're looking for?", true], + [text::IS_EQUAL_TO, 'I can see it in your eyes', false], + [text::IS_NOT_EQUAL_TO, "Hello, is it me you're looking for?", false], + [text::IS_NOT_EQUAL_TO, 'I can see it in your eyes', true], + [text::STARTS_WITH, 'Hello', true], + [text::STARTS_WITH, 'sunlight', false], + [text::ENDS_WITH, 'looking for?', true], + [text::ENDS_WITH, 'your heart', false], + [text::IS_EMPTY, null, false], + [text::IS_NOT_EMPTY, null, true], + ]; + } + + /** + * Test getting filter SQL + * + * @param int $operator + * @param string|null $value + * @param bool $expectmatch + * + * @dataProvider get_sql_filter_simple_provider + */ + public function test_get_sql_filter_simple(int $operator, ?string $value, bool $expectmatch): void { + global $DB; + + $this->resetAfterTest(); + + $course = $this->getDataGenerator()->create_course([ + 'fullname' => "Hello, is it me you're looking for?", + ]); + + $filter = new filter( + text::class, + 'test', + new lang_string('course'), + 'testentity', + 'fullname' + ); + + // Create instance of our filter, passing given operator. + [$select, $params] = text::create($filter)->get_sql_filter([ + $filter->get_unique_identifier() . '_operator' => $operator, + $filter->get_unique_identifier() . '_value' => $value, + ]); + + $fullnames = $DB->get_fieldset_select('course', 'fullname', $select, $params); + if ($expectmatch) { + $this->assertContains($course->fullname, $fullnames); + } else { + $this->assertNotContains($course->fullname, $fullnames); + } + } +} diff --git a/reportbuilder/tests/local/helpers/user_filter_manager_test.php b/reportbuilder/tests/local/helpers/user_filter_manager_test.php new file mode 100644 index 00000000000..911ab2c3355 --- /dev/null +++ b/reportbuilder/tests/local/helpers/user_filter_manager_test.php @@ -0,0 +1,233 @@ +. + +declare(strict_types=1); + +namespace core_reportbuilder\local\helpers; + +use advanced_testcase; + +/** + * Unit tests for the user filter helper + * + * @package core_reportbuilder + * @covers \core_reportbuilder\local\helpers\user_filter_manager + * @copyright 2021 Paul Holden + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class user_filter_manager_testcase extends advanced_testcase { + + /** + * Helper method to return all user preferences for filters - based on the current storage backend using the same + * + * @return array + */ + private function get_filter_preferences(): array { + return array_filter(get_user_preferences(), static function(string $key): bool { + return strpos($key, 'reportbuilder-report-') === 0; + }, ARRAY_FILTER_USE_KEY); + } + + /** + * Data provider for {@see test_get} + * + * @return array + */ + public function get_provider(): array { + return [ + 'Small value' => ['foo'], + 'Large value' => [str_repeat('A', 4000)], + 'Empty value' => [''], + ]; + } + + /** + * Test getting filter values + * + * @param string $value + * + * @dataProvider get_provider + */ + public function test_get(string $value): void { + $this->resetAfterTest(); + + $values = [ + 'entity:filter_name' => $value, + ]; + user_filter_manager::set(5, $values); + + // Make sure we get the same value back. + $this->assertEquals($values, user_filter_manager::get(5)); + } + + /** + * Test getting filter values that once spanned multiple chunks + */ + public function test_get_large_to_small(): void { + $this->resetAfterTest(); + + // Set a large initial filter value. + user_filter_manager::set(5, [ + 'longvalue' => str_repeat('ABCD', 1000), + ]); + + // Sanity check, there should be 4 (because 4000 characters plus some JSON encoding requires that many chunks). + $preferences = $this->get_filter_preferences(); + $this->assertCount(4, $preferences); + + $values = [ + 'longvalue' => 'ABCD', + ]; + user_filter_manager::set(5, $values); + + // Make sure we get the same value back. + $this->assertEquals($values, user_filter_manager::get(5)); + + // Everything should now fit in a single filter preference. + $preferences = $this->get_filter_preferences(); + $this->assertCount(1, $preferences); + } + + /** + * Test getting filter values that haven't been set + */ + public function test_get_empty(): void { + $this->assertEquals([], user_filter_manager::get(5)); + } + + /** + * Data provider for {@see test_reset_all} + * + * @return array + */ + public function reset_all_provider(): array { + return [ + 'Small value' => ['foo'], + 'Large value' => [str_repeat('A', 4000)], + 'Empty value' => [''], + ]; + } + + /** + * Test resetting all filter values + * + * @param string $value + * + * @dataProvider reset_all_provider + */ + public function test_reset_all(string $value): void { + $this->resetAfterTest(); + + user_filter_manager::set(5, [ + 'entity:filter_name' => $value + ]); + + $reset = user_filter_manager::reset_all(5); + $this->assertTrue($reset); + + // We should get an empty array back. + $this->assertEquals([], user_filter_manager::get(5)); + + // All filter preferences should be removed. + $this->assertEmpty($this->get_filter_preferences()); + } + + /** + * Test resetting single filter values + */ + public function test_reset_single(): void { + $this->resetAfterTest(); + + user_filter_manager::set(5, [ + 'entity:filter_name' => 'foo', + 'entity:filter_value' => 'bar', + 'entity:other_name' => 'baz', + 'entity:other_value' => 'bax', + ]); + + $reset = user_filter_manager::reset_single(5, 'entity:other'); + $this->assertTrue($reset); + + $this->assertEquals([ + 'entity:filter_name' => 'foo', + 'entity:filter_value' => 'bar', + ], user_filter_manager::get(5)); + } + + /** + * Test merging filter values + */ + public function test_merge(): void { + $this->resetAfterTest(); + + $values = [ + 'entity:filter_name' => 'foo', + 'entity:filter_value' => 'bar', + 'entity:filter2_name' => 'tree', + 'entity:filter2_value' => 'house', + ]; + + // Make sure we get the same value back. + user_filter_manager::set(5, $values); + $this->assertEqualsCanonicalizing($values, user_filter_manager::get(5)); + + user_filter_manager::merge(5, [ + 'entity:filter_name' => 'twotimesfoo', + 'entity:filter_value' => 'twotimesbar', + ]); + + // Make sure that both values have been changed and the other values have not been modified. + $expected = [ + 'entity:filter_name' => 'twotimesfoo', + 'entity:filter_value' => 'twotimesbar', + 'entity:filter2_name' => 'tree', + 'entity:filter2_value' => 'house', + ]; + $this->assertEqualsCanonicalizing($expected, user_filter_manager::get(5)); + } + + /** + * Test to get all filters from a given user + */ + public function test_get_all_for_user(): void { + $this->resetAfterTest(); + $user = $this->getDataGenerator()->create_user(); + $this->setUser($user); + + $filtervalues1 = [ + 'entity:filter_name' => 'foo', + 'entity:filter_value' => 'bar', + 'entity:other_name' => 'baz', + 'entity:other_value' => 'bax', + ]; + user_filter_manager::set(5, $filtervalues1); + + $filtervalues2 = [ + 'entity:filter_name' => 'blue', + 'entity:filter_value' => 'red', + ]; + user_filter_manager::set(9, $filtervalues2); + + $this->setAdminUser(); + $values = user_filter_manager::get_all_for_user((int)$user->id); + $this->assertEqualsCanonicalizing([$filtervalues1, $filtervalues2], [reset($values), end($values)]); + + // Check for a user with no filters. + $user2 = $this->getDataGenerator()->create_user(); + $values = user_filter_manager::get_all_for_user((int)$user2->id); + $this->assertEmpty($values); + } +} diff --git a/reportbuilder/tests/local/report/filter_test.php b/reportbuilder/tests/local/report/filter_test.php new file mode 100644 index 00000000000..c3f22fa8bca --- /dev/null +++ b/reportbuilder/tests/local/report/filter_test.php @@ -0,0 +1,219 @@ +. + +declare(strict_types=1); + +namespace core_reportbuilder\local\report; + +use advanced_testcase; +use lang_string; +use moodle_exception; +use core_reportbuilder\local\filters\text; + +/** + * Unit tests for a report filter + * + * @package core_reportbuilder + * @covers \core_reportbuilder\local\report\filter + * @copyright 2021 Paul Holden + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class filter_testcase extends advanced_testcase { + + /** + * Test getting filter class + */ + public function test_get_filter_class(): void { + $filter = $this->create_filter('username'); + $this->assertEquals(text::class, $filter->get_filter_class()); + } + + /** + * Test specifying invalid filter class + */ + public function test_invalid_filter_class(): void { + $this->expectException(moodle_exception::class); + $this->expectExceptionMessage('Invalid filter (sillyclass)'); + new filter('sillyclass', 'username', new lang_string('username'), 'filter_testcase'); + } + + /** + * Test getting name + */ + public function test_get_name(): void { + $filter = $this->create_filter('username'); + $this->assertEquals('username', $filter->get_name()); + } + + /** + * Test getting header + */ + public function test_get_header(): void { + $filter = $this->create_filter('username'); + $this->assertEquals('Username', $filter->get_header()); + } + + /** + * Test setting header + */ + public function test_set_header(): void { + $filter = $this->create_filter('username') + ->set_header(new lang_string('firstname')); + + $this->assertEquals('First name', $filter->get_header()); + } + + /** + * Test getting entity name + */ + public function test_get_entity_name(): void { + $filter = $this->create_filter('username'); + $this->assertEquals('filter_testcase', $filter->get_entity_name()); + } + + /** + * Test getting unique identifier + */ + public function test_get_unique_identifier(): void { + $filter = $this->create_filter('username'); + $this->assertEquals('filter_testcase:username', $filter->get_unique_identifier()); + } + + /** + * Test getting field SQL + */ + public function test_get_field_sql(): void { + $filter = $this->create_filter('username', 'u.username'); + $this->assertEquals('u.username', $filter->get_field_sql()); + } + + /** + * Test getting field params + */ + public function test_get_field_params(): void { + $filter = $this->create_filter('username', 'u.username = :foo', ['foo' => 'bar']); + $this->assertEquals(['foo' => 'bar'], $filter->get_field_params()); + } + + /** + * Test adding single join + */ + public function test_add_join(): void { + $filter = $this->create_filter('username', 'u.username'); + $this->assertEquals([], $filter->get_joins()); + + $filter->add_join('JOIN {user} u ON u.id = table.userid'); + $this->assertEquals(['JOIN {user} u ON u.id = table.userid'], $filter->get_joins()); + } + + /** + * Test adding multiple joins + */ + public function test_add_joins(): void { + $tablejoins = [ + "JOIN {course} c2 ON c2.id = c1.id", + "JOIN {course} c3 ON c3.id = c1.id", + ]; + + $filter = $this->create_filter('username', 'u.username') + ->add_joins($tablejoins); + + $this->assertEquals($tablejoins, $filter->get_joins()); + } + + /** + * Test is available + */ + public function test_is_available(): void { + $filter = $this->create_filter('username', 'u.username'); + $this->assertTrue($filter->get_is_available()); + + $filter->set_is_available(true); + $this->assertTrue($filter->get_is_available()); + } + + /** + * Test setting filter options + */ + public function test_set_options(): void { + $filter = $this->create_filter('username', 'u.username') + ->set_options([1, 2, 3]); + + $this->assertEquals([1, 2, 3], $filter->get_options()); + } + + /** + * Test setting filter options via callback + */ + public function test_set_options_callback(): void { + $filter = $this->create_filter('username', 'u.username') + ->set_options_callback(static function() { + return 10 * 5; + }); + + $this->assertEquals(50, $filter->get_options()); + } + + /** + * Test restricting filter operators + */ + public function test_limited_operators(): void { + $filter = $this->create_filter('username', 'u.username') + ->set_limited_operators([ + text::IS_EQUAL_TO, + text::IS_NOT_EQUAL_TO, + ]); + + $limitedoperators = $filter->restrict_limited_operators([ + text::CONTAINS => 'Contains', + text::DOES_NOT_CONTAIN => 'Does not contain', + text::IS_EQUAL_TO => 'Is equal to', + text::IS_NOT_EQUAL_TO => 'Is not equal to', + ]); + + $this->assertEquals([ + text::IS_EQUAL_TO => 'Is equal to', + text::IS_NOT_EQUAL_TO => 'Is not equal to', + ], $limitedoperators); + } + + /** + * Test not restricting filter operators + */ + public function test_unlimited_operators(): void { + $filter = $this->create_filter('username', 'u.username'); + + $operators = [ + text::CONTAINS => 'Contains', + text::DOES_NOT_CONTAIN => 'Does not contain', + ]; + + // If no operator limit has been set for the filter, then all available operators should be present. + $this->assertEquals($operators, $filter->restrict_limited_operators($operators)); + } + + /** + * Helper method to create a filter instance + * + * @param string $name + * @param string $fieldsql + * @param array $fieldparams + * @return filter + */ + private function create_filter(string $name, string $fieldsql = '', array $fieldparams = []): filter { + return new filter(text::class, $name, new lang_string($name), 'filter_testcase', $fieldsql, $fieldparams); + } +} From 93025be2e7cbca8a098f3926b57b60d3be3cf1b8 Mon Sep 17 00:00:00 2001 From: Paul Holden Date: Wed, 9 Dec 2020 14:23:10 +0000 Subject: [PATCH 4/9] MDL-70794 reportbuilder: management and further utility classes. We define the base classes and APIs for reports, that can contain columns and filters instances themselves. --- .../classes/local/helpers/format.php | 53 +++ reportbuilder/classes/local/models/report.php | 101 +++++ reportbuilder/classes/local/report/base.php | 420 ++++++++++++++++++ reportbuilder/classes/manager.php | 87 ++++ .../classes/report_access_exception.php | 38 ++ .../classes/source_invalid_exception.php | 40 ++ .../classes/source_unavailable_exception.php | 40 ++ reportbuilder/classes/system_report.php | 265 +++++++++++ .../classes/system_report_factory.php | 87 ++++ .../tests/local/helpers/format_test.php | 67 +++ 10 files changed, 1198 insertions(+) create mode 100644 reportbuilder/classes/local/helpers/format.php create mode 100644 reportbuilder/classes/local/models/report.php create mode 100644 reportbuilder/classes/local/report/base.php create mode 100644 reportbuilder/classes/manager.php create mode 100644 reportbuilder/classes/report_access_exception.php create mode 100644 reportbuilder/classes/source_invalid_exception.php create mode 100644 reportbuilder/classes/source_unavailable_exception.php create mode 100644 reportbuilder/classes/system_report.php create mode 100644 reportbuilder/classes/system_report_factory.php create mode 100644 reportbuilder/tests/local/helpers/format_test.php diff --git a/reportbuilder/classes/local/helpers/format.php b/reportbuilder/classes/local/helpers/format.php new file mode 100644 index 00000000000..660a3f1554c --- /dev/null +++ b/reportbuilder/classes/local/helpers/format.php @@ -0,0 +1,53 @@ +. + +declare(strict_types=1); + +namespace core_reportbuilder\local\helpers; + +use stdClass; + +/** + * Class containing helper methods for format columns data as callbacks. + * + * @package core_reportbuilder + * @copyright 2021 Sara Arjona based on Alberto Lara Hernández code. + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class format { + + /** + * Returns formatted date. + * + * @param int $value Unix timestamp + * @param stdClass $row + * @param string|null $format Format string for strftime + * @return string + */ + public static function userdate(int $value, stdClass $row, ?string $format = null): string { + return $value ? userdate($value, $format) : ''; + } + + /** + * Returns yes/no string depending on the given value + * + * @param bool $value + * @return string + */ + public static function boolean_as_text(bool $value): string { + return $value ? get_string('yes') : get_string('no'); + } +} diff --git a/reportbuilder/classes/local/models/report.php b/reportbuilder/classes/local/models/report.php new file mode 100644 index 00000000000..0c874e3bcfa --- /dev/null +++ b/reportbuilder/classes/local/models/report.php @@ -0,0 +1,101 @@ +. + +declare(strict_types=1); + +namespace core_reportbuilder\local\models; + +use context; +use context_system; +use core\persistent; +use core_reportbuilder\local\report\base; + +/** + * Persistent class to represent a report + * + * @package core_reportbuilder + * @copyright 2018 Alberto Lara Hernández + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class report extends persistent { + + /** @var string The table name. */ + public const TABLE = 'reportbuilder_report'; + + /** + * Return the definition of the properties of this model + * + * @return array + */ + protected static function define_properties(): array { + return [ + 'source' => [ + 'type' => PARAM_RAW, + ], + 'type' => [ + 'type' => PARAM_INT, + 'choices' => [ + base::TYPE_CUSTOM_REPORT, + base::TYPE_SYSTEM_REPORT, + ], + ], + 'contextid' => [ + 'type' => PARAM_INT, + 'default' => static function(): int { + return context_system::instance()->id; + } + ], + 'component' => [ + 'type' => PARAM_COMPONENT, + 'default' => '', + ], + 'area' => [ + 'type' => PARAM_AREA, + 'default' => '', + ], + 'itemid' => [ + 'type' => PARAM_INT, + 'default' => 0, + ], + 'usercreated' => [ + 'type' => PARAM_INT, + 'default' => static function(): int { + global $USER; + + return (int) $USER->id; + }, + ], + ]; + } + + /** + * Return report ID + * + * @return int + */ + protected function get_id(): int { + return (int) $this->raw_get('id'); + } + + /** + * Return report context, used by exporters + * + * @return context + */ + public function get_context(): context { + return context::instance_by_id($this->raw_get('contextid')); + } +} diff --git a/reportbuilder/classes/local/report/base.php b/reportbuilder/classes/local/report/base.php new file mode 100644 index 00000000000..d4616d99781 --- /dev/null +++ b/reportbuilder/classes/local/report/base.php @@ -0,0 +1,420 @@ +. + +declare(strict_types=1); + +namespace core_reportbuilder\local\report; + +use coding_exception; +use context; +use lang_string; +use core_reportbuilder\local\filters\base as filter_base; +use core_reportbuilder\local\helpers\database; +use core_reportbuilder\local\helpers\user_filter_manager; +use core_reportbuilder\local\models\report; + +/** + * Base class for all reports + * + * @package core_reportbuilder + * @copyright 2020 Paul Holden + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +abstract class base { + + /** @var int Custom report type value */ + public const TYPE_CUSTOM_REPORT = 0; + + /** @var int System report type value */ + public const TYPE_SYSTEM_REPORT = 1; + + /** @var int Default paging limit */ + public const DEFAULT_PAGESIZE = 30; + + /** @var report $report Report persistent */ + private $report; + + /** @var string $maintable */ + private $maintable = ''; + + /** @var string $maintablealias */ + private $maintablealias = ''; + + /** @var array $sqljoins */ + private $sqljoins = []; + + /** @var array $sqlwheres */ + private $sqlwheres = []; + + /** @var array $sqlparams */ + private $sqlparams = []; + + /** @var lang_string[] */ + private $entitytitles = []; + + /** @var column[] $columns */ + private $columns = []; + + /** @var filter[] $filters */ + private $filters = []; + + /** @var bool $downloadable Set if the report can be downloaded */ + private $downloadable = false; + + /** @var string $downloadfilename Name of the downloaded file */ + private $downloadfilename = ''; + + /** + * Base report constructor + * + * @param report $report + */ + public function __construct(report $report) { + $this->report = $report; + + // Initialise and validate the report. + $this->initialise(); + $this->validate(); + } + + /** + * Returns persistent class used when initialising this report + * + * @return report + */ + final public function get_report_persistent(): report { + return $this->report; + } + + /** + * Initialise report. Specify which columns, filters, etc should be present + * + * To set the base query use: + * - {@see set_main_table} + * - {@see add_base_condition_simple} or {@see add_base_condition_sql} + * - {@see add_join} + * + * To add content to the report use: + * - {@see add_entity} + * - {@see add_column} + * - {@see add_filter} + * - etc + */ + abstract protected function initialise(): void; + + /** + * Get the report availability. Sub-classes should override this method to declare themselves unavailable, for example if + * they require classes that aren't present due to missing plugin + * + * @return bool + */ + public static function is_available(): bool { + return true; + } + + /** + * Perform some basic validation about expected class properties + * + * @throws coding_exception + */ + protected function validate(): void { + if (empty($this->maintable)) { + throw new coding_exception('Report must define main table by calling $this->set_main_table()'); + } + + if (empty($this->columns)) { + throw new coding_exception('Report must define at least one column by calling $this->add_column()'); + } + } + + /** + * Set the main table and alias for the SQL query + * + * @param string $tablename + * @param string $tablealias + */ + final public function set_main_table(string $tablename, string $tablealias = ''): void { + $this->maintable = $tablename; + $this->maintablealias = $tablealias; + } + + /** + * Get the main table name + * + * @return string + */ + final public function get_main_table(): string { + return $this->maintable; + } + + /** + * Get the alias for the main table + * + * @return string + */ + final public function get_main_table_alias(): string { + return $this->maintablealias; + } + + /** + * Adds report JOIN clause that is always added + * + * @param string $join + * @param array $params + * @param bool $validateparams Some queries might add non-standard params and validation could fail + */ + protected function add_join(string $join, array $params = [], bool $validateparams = true): void { + if ($validateparams) { + database::validate_params($params); + } + + $this->sqljoins[trim($join)] = trim($join); + $this->sqlparams += $params; + } + + /** + * Return report JOIN clauses + * + * @return array + */ + public function get_joins(): array { + return array_values($this->sqljoins); + } + + /** + * Define simple "field = value" clause to apply to the report query + * + * @param string $fieldname + * @param mixed $fieldvalue + */ + final public function add_base_condition_simple(string $fieldname, $fieldvalue): void { + if ($fieldvalue === null) { + $this->add_base_condition_sql("{$fieldname} IS NULL"); + } else { + $fieldvalueparam = database::generate_param_name(); + $this->add_base_condition_sql("{$fieldname} = :{$fieldvalueparam}", [ + $fieldvalueparam => $fieldvalue, + ]); + } + } + + /** + * Define more complex clause that will always be applied to the report query + * + * @param string $where + * @param array $params Note that the param names should be generated by {@see database::generate_param_name} + */ + final public function add_base_condition_sql(string $where, array $params = []): void { + database::validate_params($params); + + $this->sqlwheres[] = trim($where); + $this->sqlparams = $params + $this->sqlparams; + } + + /** + * Return base select/params for the report query + * + * @return array [string $select, array $params] + */ + final public function get_base_condition(): array { + return [ + implode(' AND ', $this->sqlwheres), + $this->sqlparams, + ]; + } + + /** + * Define a new entity for the report + * + * @param string $name + * @param lang_string $title + * @throws coding_exception + */ + final protected function annotate_entity(string $name, lang_string $title): void { + if (empty($name) || $name !== clean_param($name, PARAM_ALPHANUMEXT)) { + throw new coding_exception('Entity name must be comprised of alphanumeric character, underscore or dash'); + } + + $this->entitytitles[$name] = $title; + } + + /** + * Adds a column to the report + * + * @param column $column + * @return column + * @throws coding_exception + */ + final protected function add_column(column $column): column { + if (!array_key_exists($column->get_entity_name(), $this->entitytitles)) { + throw new coding_exception('Invalid entity name', $column->get_entity_name()); + } + + $name = $column->get_name(); + if (empty($name) || $name !== clean_param($name, PARAM_ALPHANUMEXT)) { + throw new coding_exception('Column name must be comprised of alphanumeric character, underscore or dash'); + } + + $uniqueidentifier = $column->get_unique_identifier(); + if (array_key_exists($uniqueidentifier, $this->columns)) { + throw new coding_exception('Duplicate column identifier', $uniqueidentifier); + } + + $this->columns[$uniqueidentifier] = $column; + + return $column; + } + + /** + * Return report column by unique identifier + * + * @param string $uniqueidentifier + * @return column|null + */ + final public function get_column(string $uniqueidentifier): ?column { + return $this->columns[$uniqueidentifier] ?? null; + } + + /** + * Return all available report columns + * + * @return column[] + */ + final public function get_columns(): array { + return array_filter($this->columns, static function(column $column): bool { + return $column->get_is_available(); + }); + } + + /** + * Adds a filter to the report + * + * @param filter $filter + * @return filter + * @throws coding_exception + */ + final protected function add_filter(filter $filter): filter { + if (!array_key_exists($filter->get_entity_name(), $this->entitytitles)) { + throw new coding_exception('Invalid entity name', $filter->get_entity_name()); + } + + $name = $filter->get_name(); + if (empty($name) || $name !== clean_param($name, PARAM_ALPHANUMEXT)) { + throw new coding_exception('Filter name must be comprised of alphanumeric character, underscore or dash'); + } + + $uniqueidentifier = $filter->get_unique_identifier(); + if (array_key_exists($uniqueidentifier, $this->filters)) { + throw new coding_exception('Duplicate filter identifier', $uniqueidentifier); + } + + $this->filters[$uniqueidentifier] = $filter; + + return $filter; + } + + /** + * Return report filter by unique identifier + * + * @param string $uniqueidentifier + * @return filter|null + */ + final public function get_filter(string $uniqueidentifier): ?filter { + return $this->filters[$uniqueidentifier] ?? null; + } + + /** + * Return all available report filters + * + * @return filter[] + */ + final public function get_filters(): array { + return array_filter($this->filters, static function(filter $filter): bool { + return $filter->get_is_available(); + }); + } + + /** + * Return all report filter instances + * + * @return filter_base[] + */ + final public function get_filter_instances(): array { + return array_map(static function(filter $filter): filter_base { + /** @var filter_base $filterclass */ + $filterclass = $filter->get_filter_class(); + + return $filterclass::create($filter); + }, $this->get_filters()); + } + + /** + * Set the filter values of the report + * + * @param array $values + * @return bool + */ + final public function set_filter_values(array $values): bool { + return user_filter_manager::set($this->report->get('id'), $values); + } + + /** + * Get the filter values of the report + * + * @return array + */ + final public function get_filter_values(): array { + return user_filter_manager::get($this->report->get('id')); + } + + /** + * Set if the report can be downloaded. + * + * @param bool $downloadable + * @param string $downloadfilename If the report is downloadable, then a filename should be provided here + */ + final public function set_downloadable(bool $downloadable, string $downloadfilename = 'export'): void { + $this->downloadable = $downloadable; + $this->downloadfilename = $downloadfilename; + } + + /** + * Get if the report can be downloaded. + * + * @return bool + */ + final public function is_downloadable(): bool { + return $this->downloadable; + } + + /** + * Return the downloadable report filename + * + * @return string + */ + final public function get_downloadfilename(): string { + return $this->downloadfilename; + } + + /** + * Returns the report context + * + * @return context + */ + public function get_context(): context { + return $this->report->get_context(); + } +} diff --git a/reportbuilder/classes/manager.php b/reportbuilder/classes/manager.php new file mode 100644 index 00000000000..550e7f3153a --- /dev/null +++ b/reportbuilder/classes/manager.php @@ -0,0 +1,87 @@ +. + +declare(strict_types=1); + +namespace core_reportbuilder; + +use stdClass; +use core_reportbuilder\local\models\report; +use core_reportbuilder\local\report\base; + +/** + * Report management class + * + * @package core_reportbuilder + * @copyright 2020 Paul Holden + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class manager { + + /** + * Return an instance of a report class from the given report persistent + * + * @param report $report + * @param array $parameters + * @return base + * @throws source_invalid_exception + * @throws source_unavailable_exception + */ + public static function get_report_from_persistent(report $report, array $parameters = []): base { + $source = $report->get('source'); + + // Throw exception for invalid or unavailable report source. + if (!self::report_source_exists($source)) { + throw new source_invalid_exception($source); + } else if (!self::report_source_available($source)) { + throw new source_unavailable_exception($source); + } + + return new $source($report, $parameters); + } + + /** + * Verify that report source exists and extends appropriate base classes + * + * @param string $source Full namespaced path to report definition + * @param string $additionalbaseclass Specify addition base class that given classname should extend + * @return bool + */ + public static function report_source_exists(string $source, string $additionalbaseclass = ''): bool { + return (class_exists($source) && is_subclass_of($source, base::class) && + (empty($additionalbaseclass) || is_subclass_of($source, $additionalbaseclass))); + } + + /** + * Verify given report source is available. Note that it is assumed caller has already checked that it exists + * + * @param string $source + * @return bool + */ + public static function report_source_available(string $source): bool { + return call_user_func([$source, 'is_available']); + } + + /** + * Create new report persistent + * + * @param stdClass $reportdata + * @return report + */ + public static function create_report_persistent(stdClass $reportdata): report { + return (new report(0, $reportdata))->create(); + } +} diff --git a/reportbuilder/classes/report_access_exception.php b/reportbuilder/classes/report_access_exception.php new file mode 100644 index 00000000000..3783747553f --- /dev/null +++ b/reportbuilder/classes/report_access_exception.php @@ -0,0 +1,38 @@ +. + +declare(strict_types=1); + +namespace core_reportbuilder; + +use moodle_exception; + +/** + * User cannot access report exception + * + * @package core_reportbuilder + * @copyright 2021 Paul Holden + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class report_access_exception extends moodle_exception { + + /** + * Constructor + */ + public function __construct() { + parent::__construct('errorreportaccess', 'reportbuilder'); + } +} diff --git a/reportbuilder/classes/source_invalid_exception.php b/reportbuilder/classes/source_invalid_exception.php new file mode 100644 index 00000000000..6edd067a1f4 --- /dev/null +++ b/reportbuilder/classes/source_invalid_exception.php @@ -0,0 +1,40 @@ +. + +declare(strict_types=1); + +namespace core_reportbuilder; + +use moodle_exception; + +/** + * Invalid report source exception + * + * @package core_reportbuilder + * @copyright 2020 Paul Holden + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class source_invalid_exception extends moodle_exception { + + /** + * Constructor + * + * @param string $source + */ + public function __construct(string $source) { + parent::__construct('errorsourceinvalid', 'reportbuilder', '', null, $source); + } +} diff --git a/reportbuilder/classes/source_unavailable_exception.php b/reportbuilder/classes/source_unavailable_exception.php new file mode 100644 index 00000000000..dc439a691be --- /dev/null +++ b/reportbuilder/classes/source_unavailable_exception.php @@ -0,0 +1,40 @@ +. + +declare(strict_types=1); + +namespace core_reportbuilder; + +use moodle_exception; + +/** + * Unavailable report source exception + * + * @package core_reportbuilder + * @copyright 2020 Paul Holden + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class source_unavailable_exception extends moodle_exception { + + /** + * Constructor + * + * @param string $source + */ + public function __construct(string $source) { + parent::__construct('errorsourceunavailable', 'reportbuilder', '', null, $source); + } +} diff --git a/reportbuilder/classes/system_report.php b/reportbuilder/classes/system_report.php new file mode 100644 index 00000000000..691e693365b --- /dev/null +++ b/reportbuilder/classes/system_report.php @@ -0,0 +1,265 @@ +. + +declare(strict_types=1); + +namespace core_reportbuilder; + +use coding_exception; +use stdClass; +use core_reportbuilder\local\models\report; +use core_reportbuilder\local\report\action; +use core_reportbuilder\local\report\base; +use core_reportbuilder\local\report\column; + +/** + * Base class for system reports + * + * @package core_reportbuilder + * @copyright 2020 Paul Holden + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +abstract class system_report extends base { + + /** @var array $parameters */ + private $parameters; + + /** @var string[] $basefields List of base fields */ + private $basefields = []; + + /** @var action[] $actions */ + private $actions = []; + + /** @var column $initialsortcolumn */ + private $initialsortcolumn; + + /** @var int $initialsortdirection */ + private $initialsortdirection; + + /** + * System report constructor. + * + * @param report $report + * @param array $parameters + */ + final public function __construct(report $report, array $parameters) { + $this->parameters = $parameters; + + parent::__construct($report); + } + + /** + * Validates access to view this report + * + * This is necessary to implement independently of the page that would typically embed the report because + * subsequent pages are requested via AJAX requests, and access should be validated each time + * + * @return bool + */ + abstract protected function can_view(): bool; + + /** + * Validate access to the report + * + * @throws report_access_exception + */ + final public function require_can_view(): void { + if (!$this->can_view()) { + throw new report_access_exception(); + } + } + + /** + * Report validation + * + * @throws report_access_exception If user cannot access the report + * @throws coding_exception If no default column are specified + */ + protected function validate(): void { + parent::validate(); + + $this->require_can_view(); + + // Ensure the report has some default columns specified. + if (empty($this->get_columns())) { + throw new coding_exception('No columns added'); + } + } + + /** + * Add list of fields that have to be always included in SQL query for actions and row classes + * + * Base fields are only available in system reports because they are not compatible with aggregation + * + * @param string $sql SQL clause for the list of fields that only uses main table or base joins + */ + final protected function add_base_fields(string $sql): void { + $this->basefields[] = $sql; + } + + /** + * Return report base fields + * + * @return array + */ + final public function get_base_fields(): array { + return $this->basefields; + } + + /** + * Adds an action to the report + * + * @param action $action + */ + final public function add_action(action $action): void { + $this->actions[] = $action; + } + + /** + * Whether report has any actions + * + * @return bool + */ + final public function has_actions(): bool { + return !empty($this->actions); + } + + /** + * Return report actions + * + * @return action[] + */ + final public function get_actions(): array { + return $this->actions; + } + + /** + * Set all report parameters + * + * @param array $parameters + */ + final public function set_parameters(array $parameters): void { + $this->parameters = $parameters; + } + + /** + * Return all report parameters + * + * @return array + */ + final public function get_parameters(): array { + return $this->parameters; + } + + /** + * Return specific report parameter + * + * @param string $param + * @param mixed $default + * @param string $type + * @return mixed + */ + final public function get_parameter(string $param, $default, string $type) { + if (!array_key_exists($param, $this->parameters)) { + return $default; + } + + return clean_param($this->parameters[$param], $type); + } + + /** + * CSS classes to add to the row. Can be overridden by system reports do define class to be added to output according to + * content of each row + * + * @param stdClass $row + * @return string + */ + public function get_row_class(stdClass $row): string { + return ''; + } + + /** + * Default 'per page' size. Can be overridden by system reports to define a different paging value + * + * @return int + */ + public function get_default_per_page(): int { + return self::DEFAULT_PAGESIZE; + } + + /** + * Called before rendering each row. Can be overridden to pre-fetch/create objects and store them in the class, which can + * later be used in column and action callbacks + * + * @param stdClass $row + */ + public function row_callback(stdClass $row): void { + return; + } + + /** + * Validates access to download this report. + * + * @return bool + */ + final public function can_be_downloaded(): bool { + return $this->can_view() && $this->is_downloadable(); + } + + /** + * Return list of column names that will be excluded when table is downloaded. Extending classes should override this method + * as appropriate + * + * @return string[] Array of column unique identifiers + */ + public function get_exclude_columns_for_download(): array { + return []; + } + + /** + * Set initial sort column and sort direction for the report + * + * @param string $uniqueidentifier + * @param int $sortdirection One of SORT_ASC or SORT_DESC + * @throws coding_exception + */ + public function set_initial_sort_column(string $uniqueidentifier, int $sortdirection): void { + if (!$sortcolumn = $this->get_column($uniqueidentifier)) { + throw new coding_exception('Unknown column identifier', $uniqueidentifier); + } + + $this->initialsortcolumn = $sortcolumn; + $this->initialsortdirection = $sortdirection; + } + + /** + * Get initial sort column + * + * @return column|null + */ + public function get_initial_sort_column(): ?column { + return $this->initialsortcolumn; + } + + /** + * Get initial sort column direction + * + * @return int + */ + public function get_initial_sort_direction(): int { + return $this->initialsortdirection; + } +} diff --git a/reportbuilder/classes/system_report_factory.php b/reportbuilder/classes/system_report_factory.php new file mode 100644 index 00000000000..36cbb4a15a0 --- /dev/null +++ b/reportbuilder/classes/system_report_factory.php @@ -0,0 +1,87 @@ +. + +declare(strict_types=1); + +namespace core_reportbuilder; + +use context; +use core_reportbuilder\local\models\report; +use core_reportbuilder\local\report\base; + +/** + * Factory class for creating system report instances + * + * @package core_reportbuilder + * @copyright 2020 Paul Holden + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class system_report_factory { + + /** + * Create and return instance of given system report source + * + * @param string $source Class path of system report definition + * @param context $context + * @param string $component + * @param string $area + * @param int $itemid + * @param array $parameters Simple key/value pairs, accessed inside reports using $this->get_parameter() + * @return system_report + * @throws source_invalid_exception + */ + public static function create(string $source, context $context, string $component = '', string $area = '', + int $itemid = 0, array $parameters = []): system_report { + + // Exit early if source isn't a system report. + if (!manager::report_source_exists($source, system_report::class)) { + throw new source_invalid_exception($source); + } + + $report = static::get_report_persistent($source, $context, $component, $area, $itemid); + + return manager::get_report_from_persistent($report, $parameters); + } + + /** + * Given a report source, with accompanying context information, return a persistent report instance + * + * @param string $source + * @param context $context + * @param string $component + * @param string $area + * @param int $itemid + * @return report + */ + private static function get_report_persistent(string $source, context $context, string $component = '', string $area = '', + int $itemid = 0): report { + + $reportdata = [ + 'type' => base::TYPE_SYSTEM_REPORT, + 'source' => $source, + 'contextid' => $context->id, + 'component' => $component, + 'area' => $area, + 'itemid' => $itemid, + ]; + + if ($report = report::get_record($reportdata)) { + return $report; + } + + return manager::create_report_persistent((object) $reportdata); + } +} diff --git a/reportbuilder/tests/local/helpers/format_test.php b/reportbuilder/tests/local/helpers/format_test.php new file mode 100644 index 00000000000..dd45a4cdb24 --- /dev/null +++ b/reportbuilder/tests/local/helpers/format_test.php @@ -0,0 +1,67 @@ +. + +declare(strict_types=1); + +namespace core_reportbuilder\local\helpers; + +use advanced_testcase; +use stdClass; + +/** + * Unit tests for the format helper + * + * @package core_reportbuilder + * @covers \core_reportbuilder\local\helpers\format + * @copyright 2021 Paul Holden + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class format_testcase extends advanced_testcase { + + /** + * Test userdate method + */ + public function test_userdate(): void { + $now = time(); + + $userdate = format::userdate($now, new stdClass()); + $this->assertEquals(userdate($now), $userdate); + } + + /** + * Data provider for {@see test_boolean_as_text} + * + * @return array + */ + public function boolean_as_text_provider(): array { + return [ + [false, get_string('no')], + [true, get_string('yes')], + ]; + } + + /** + * Test boolean as text + * + * @param bool $value + * @param string $expected + * + * @dataProvider boolean_as_text_provider + */ + public function test_boolean_as_text(bool $value, string $expected): void { + $this->assertEquals($expected, format::boolean_as_text($value)); + } +} From 514caaa4ea33890d462a780038d7706658618753 Mon Sep 17 00:00:00 2001 From: Paul Holden Date: Fri, 11 Dec 2020 15:06:05 +0000 Subject: [PATCH 5/9] MDL-70794 reportbuilder: output components and classes. This change contains most of the output components required for reports such as exporters, templates, AMD modules. Also included are classes within the component table namespace which are required for extending the dynamic table API. --- lang/en/reportbuilder.php | 7 +- reportbuilder/amd/build/local/events.min.js | 2 + .../amd/build/local/events.min.js.map | 1 + .../amd/build/local/selectors.min.js | 2 + .../amd/build/local/selectors.min.js.map | 1 + reportbuilder/amd/build/report.min.js | 2 + reportbuilder/amd/build/report.min.js.map | 1 + reportbuilder/amd/src/local/events.js | 29 ++ reportbuilder/amd/src/local/selectors.js | 34 ++ reportbuilder/amd/src/report.js | 49 +++ .../external/system_report_exporter.php | 115 ++++++ reportbuilder/classes/form/filter.php | 139 ++++++++ reportbuilder/classes/local/report/base.php | 7 + reportbuilder/classes/output/renderer.php | 59 ++++ .../classes/output/system_report.php | 74 ++++ reportbuilder/classes/system_report.php | 17 + .../classes/table/system_report_table.php | 329 ++++++++++++++++++ .../table/system_report_table_filterset.php | 42 +++ .../system_report_table_parameters_filter.php | 32 ++ reportbuilder/download.php | 55 +++ .../templates/system_report.mustache | 46 +++ .../external/system_report_exporter_test.php | 94 +++++ .../fixtures/system_report_available.php | 95 +++++ .../fixtures/system_report_unavailable.php | 42 +++ .../fixtures/testable_system_report_table.php | 75 ++++ .../tests/local/report/base_test.php | 153 ++++++++ reportbuilder/tests/manager_test.php | 132 +++++++ .../tests/system_report_factory_test.php | 85 +++++ reportbuilder/tests/system_report_test.php | 94 +++++ 29 files changed, 1812 insertions(+), 1 deletion(-) create mode 100644 reportbuilder/amd/build/local/events.min.js create mode 100644 reportbuilder/amd/build/local/events.min.js.map create mode 100644 reportbuilder/amd/build/local/selectors.min.js create mode 100644 reportbuilder/amd/build/local/selectors.min.js.map create mode 100644 reportbuilder/amd/build/report.min.js create mode 100644 reportbuilder/amd/build/report.min.js.map create mode 100644 reportbuilder/amd/src/local/events.js create mode 100644 reportbuilder/amd/src/local/selectors.js create mode 100644 reportbuilder/amd/src/report.js create mode 100644 reportbuilder/classes/external/system_report_exporter.php create mode 100644 reportbuilder/classes/form/filter.php create mode 100644 reportbuilder/classes/output/renderer.php create mode 100644 reportbuilder/classes/output/system_report.php create mode 100644 reportbuilder/classes/table/system_report_table.php create mode 100644 reportbuilder/classes/table/system_report_table_filterset.php create mode 100644 reportbuilder/classes/table/system_report_table_parameters_filter.php create mode 100644 reportbuilder/download.php create mode 100644 reportbuilder/templates/system_report.mustache create mode 100644 reportbuilder/tests/external/system_report_exporter_test.php create mode 100644 reportbuilder/tests/fixtures/system_report_available.php create mode 100644 reportbuilder/tests/fixtures/system_report_unavailable.php create mode 100644 reportbuilder/tests/fixtures/testable_system_report_table.php create mode 100644 reportbuilder/tests/local/report/base_test.php create mode 100644 reportbuilder/tests/manager_test.php create mode 100644 reportbuilder/tests/system_report_factory_test.php create mode 100644 reportbuilder/tests/system_report_test.php diff --git a/lang/en/reportbuilder.php b/lang/en/reportbuilder.php index 5d4c70efef6..34f759cbbae 100644 --- a/lang/en/reportbuilder.php +++ b/lang/en/reportbuilder.php @@ -22,6 +22,11 @@ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ +$string['actions'] = 'Actions'; +$string['apply'] = 'Apply'; +$string['errorreportaccess'] = 'You can not view this report'; +$string['errorsourceinvalid'] = 'Could not find valid report source'; +$string['errorsourceunavailable'] = 'Report source is not available'; $string['filtercontains'] = 'Contains'; $string['filterdatefrom'] = 'Date from'; $string['filterdateto'] = 'Date to'; @@ -43,5 +48,5 @@ $string['filterrange'] = 'Range'; $string['filtersapplied'] = 'Filters applied'; $string['filtersreset'] = 'Filters reset'; $string['filterstartswith'] = 'Starts with'; -$string['privacy:metadata:preference:reportfilter'] = 'The stored report filters for this user'; +$string['resetall'] = 'Reset all'; $string['selectcourses'] = 'Select courses'; diff --git a/reportbuilder/amd/build/local/events.min.js b/reportbuilder/amd/build/local/events.min.js new file mode 100644 index 00000000000..6a67e2031ef --- /dev/null +++ b/reportbuilder/amd/build/local/events.min.js @@ -0,0 +1,2 @@ +define ("core_reportbuilder/local/events",["exports"],function(a){"use strict";Object.defineProperty(a,"__esModule",{value:!0});a.default=void 0;a.default={tableReload:"core_reportbuilder_table_reload"};return a.default}); +//# sourceMappingURL=events.min.js.map diff --git a/reportbuilder/amd/build/local/events.min.js.map b/reportbuilder/amd/build/local/events.min.js.map new file mode 100644 index 00000000000..18d9e47834d --- /dev/null +++ b/reportbuilder/amd/build/local/events.min.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["../../src/local/events.js"],"names":["tableReload"],"mappings":"2JAwBe,CAGXA,WAAW,CAAE,iCAHF,C","sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/**\n * Report builder events\n *\n * @module core_reportbuilder/local/events\n * @package core_reportbuilder\n * @copyright 2021 Paul Holden \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\nexport default {\n // Trigger table reloading, passing optional `preservePagination` details argument can be used to\n // define whether current pagination should be preserved (default is false, i.e. reload first page.)\n tableReload: 'core_reportbuilder_table_reload',\n};\n"],"file":"events.min.js"} \ No newline at end of file diff --git a/reportbuilder/amd/build/local/selectors.min.js b/reportbuilder/amd/build/local/selectors.min.js new file mode 100644 index 00000000000..91e9a6aa3bf --- /dev/null +++ b/reportbuilder/amd/build/local/selectors.min.js @@ -0,0 +1,2 @@ +define ("core_reportbuilder/local/selectors",["exports"],function(a){"use strict";Object.defineProperty(a,"__esModule",{value:!0});a.default=void 0;var b={regions:{systemReport:"[data-region=\"core_reportbuilder/system-report\"]",filtersForm:"[data-region=\"filters-form\"]"}};b.forSystemReport=function(a){return"".concat(b.regions.systemReport,"[data-reportid=\"").concat(a,"\"]")};a.default=b;return a.default}); +//# sourceMappingURL=selectors.min.js.map diff --git a/reportbuilder/amd/build/local/selectors.min.js.map b/reportbuilder/amd/build/local/selectors.min.js.map new file mode 100644 index 00000000000..837b0d11c4e --- /dev/null +++ b/reportbuilder/amd/build/local/selectors.min.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["../../src/local/selectors.js"],"names":["SELECTORS","regions","systemReport","filtersForm","forSystemReport","reportId"],"mappings":"oJAwBA,GAAMA,CAAAA,CAAS,CAAG,CACdC,OAAO,CAAE,CACLC,YAAY,CAAE,oDADT,CAELC,WAAW,CAAE,gCAFR,CADK,CAAlB,CAOAH,CAAS,CAACI,eAAV,CAA4B,SAAAC,CAAQ,kBAAOL,CAAS,CAACC,OAAV,CAAkBC,YAAzB,6BAAwDG,CAAxD,QAApC,C,UAEeL,C","sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/**\n * Report builder selectors\n *\n * @module core_reportbuilder/local/selectors\n * @package core_reportbuilder\n * @copyright 2021 Paul Holden \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\nconst SELECTORS = {\n regions: {\n systemReport: '[data-region=\"core_reportbuilder/system-report\"]',\n filtersForm: '[data-region=\"filters-form\"]',\n },\n};\n\nSELECTORS.forSystemReport = reportId => `${SELECTORS.regions.systemReport}[data-reportid=\"${reportId}\"]`;\n\nexport default SELECTORS;\n"],"file":"selectors.min.js"} \ No newline at end of file diff --git a/reportbuilder/amd/build/report.min.js b/reportbuilder/amd/build/report.min.js new file mode 100644 index 00000000000..3b80c88f006 --- /dev/null +++ b/reportbuilder/amd/build/report.min.js @@ -0,0 +1,2 @@ +function _typeof(a){"@babel/helpers - typeof";if("function"==typeof Symbol&&"symbol"==typeof Symbol.iterator){_typeof=function(a){return typeof a}}else{_typeof=function(a){return a&&"function"==typeof Symbol&&a.constructor===Symbol&&a!==Symbol.prototype?"symbol":typeof a}}return _typeof(a)}define ("core_reportbuilder/report",["exports","core_reportbuilder/local/events","core_reportbuilder/local/selectors","core_table/dynamic","core_table/local/dynamic/selectors"],function(a,b,c,d,e){"use strict";Object.defineProperty(a,"__esModule",{value:!0});a.init=void 0;b=g(b);c=g(c);e=g(e);function f(){if("function"!=typeof WeakMap)return null;var a=new WeakMap;f=function(){return a};return a}function g(a){if(a&&a.__esModule){return a}if(null===a||"object"!==_typeof(a)&&"function"!=typeof a){return{default:a}}var b=f();if(b&&b.has(a)){return b.get(a)}var c={},d=Object.defineProperty&&Object.getOwnPropertyDescriptor;for(var e in a){if(Object.prototype.hasOwnProperty.call(a,e)){var g=d?Object.getOwnPropertyDescriptor(a,e):null;if(g&&(g.get||g.set)){Object.defineProperty(c,e,g)}else{c[e]=a[e]}}}c.default=a;if(b){b.set(a,c)}return c}function h(a,b,c,d,e,f,g){try{var h=a[f](g),i=h.value}catch(a){c(a);return}if(h.done){b(i)}else{Promise.resolve(i).then(d,e)}}function i(a){return function(){var b=this,c=arguments;return new Promise(function(d,e){var i=a.apply(b,c);function f(a){h(i,d,e,f,g,"next",a)}function g(a){h(i,d,e,f,g,"throw",a)}f(void 0)})}}var j=function(a){document.addEventListener(b.tableReload,function(){var b=i(regeneratorRuntime.mark(function b(f){var g,h,i,j;return regeneratorRuntime.wrap(function(b){while(1){switch(b.prev=b.next){case 0:h=f.target.closest(c.forSystemReport(a));if(!(null===h)){b.next=3;break}return b.abrupt("return");case 3:i=h.querySelector(e.main.region);j=(null===(g=f.detail)||void 0===g?void 0:g.preservePagination)?null:1;b.next=7;return(0,d.setPageNumber)(i,j,!1).then(d.refreshTableContent);case 7:case"end":return b.stop();}}},b)}));return function(){return b.apply(this,arguments)}}())};a.init=j}); +//# sourceMappingURL=report.min.js.map diff --git a/reportbuilder/amd/build/report.min.js.map b/reportbuilder/amd/build/report.min.js.map new file mode 100644 index 00000000000..43ae097ee25 --- /dev/null +++ b/reportbuilder/amd/build/report.min.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["../src/report.js"],"names":["init","reportId","document","addEventListener","reportEvents","tableReload","event","triggerElement","target","closest","reportSelectors","forSystemReport","tableRoot","querySelector","tableSelectors","main","region","pageNumber","detail","preservePagination","then","refreshTableContent"],"mappings":"ojBAwBA,OACA,OAEA,O,q2BAOO,GAAMA,CAAAA,CAAI,CAAG,SAAAC,CAAQ,CAAI,CAE5BC,QAAQ,CAACC,gBAAT,CAA0BC,CAAY,CAACC,WAAvC,4CAAoD,WAAMC,CAAN,+FAC1CC,CAD0C,CACzBD,CAAK,CAACE,MAAN,CAAaC,OAAb,CAAqBC,CAAe,CAACC,eAAhB,CAAgCV,CAAhC,CAArB,CADyB,MAEzB,IAAnB,GAAAM,CAF4C,mDAM1CK,CAN0C,CAM9BL,CAAc,CAACM,aAAf,CAA6BC,CAAc,CAACC,IAAf,CAAoBC,MAAjD,CAN8B,CAO1CC,CAP0C,CAO7B,WAAAX,CAAK,CAACY,MAAN,uBAAcC,kBAAd,EAAmC,IAAnC,CAA0C,CAPb,gBAS1C,oBAAcP,CAAd,CAAyBK,CAAzB,KACDG,IADC,CACIC,qBADJ,CAT0C,yCAApD,wDAYH,CAdM,C","sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/**\n * Report builder report management\n *\n * @module core_reportbuilder/report\n * @package core_reportbuilder\n * @copyright 2021 Paul Holden \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\nimport * as reportEvents from 'core_reportbuilder/local/events';\nimport * as reportSelectors from 'core_reportbuilder/local/selectors';\nimport {setPageNumber, refreshTableContent} from 'core_table/dynamic';\nimport * as tableSelectors from 'core_table/local/dynamic/selectors';\n\n/**\n * Initialise module\n *\n * @param {Number} reportId\n */\nexport const init = reportId => {\n // Listen for the table reload event.\n document.addEventListener(reportEvents.tableReload, async(event) => {\n const triggerElement = event.target.closest(reportSelectors.forSystemReport(reportId));\n if (triggerElement === null) {\n return;\n }\n\n const tableRoot = triggerElement.querySelector(tableSelectors.main.region);\n const pageNumber = event.detail?.preservePagination ? null : 1;\n\n await setPageNumber(tableRoot, pageNumber, false)\n .then(refreshTableContent);\n });\n};\n"],"file":"report.min.js"} \ No newline at end of file diff --git a/reportbuilder/amd/src/local/events.js b/reportbuilder/amd/src/local/events.js new file mode 100644 index 00000000000..7d76be107b9 --- /dev/null +++ b/reportbuilder/amd/src/local/events.js @@ -0,0 +1,29 @@ +// This file is part of Moodle - http://moodle.org/ +// +// Moodle is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Moodle is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Moodle. If not, see . + +/** + * Report builder events + * + * @module core_reportbuilder/local/events + * @package core_reportbuilder + * @copyright 2021 Paul Holden + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +export default { + // Trigger table reloading, passing optional `preservePagination` details argument can be used to + // define whether current pagination should be preserved (default is false, i.e. reload first page.) + tableReload: 'core_reportbuilder_table_reload', +}; diff --git a/reportbuilder/amd/src/local/selectors.js b/reportbuilder/amd/src/local/selectors.js new file mode 100644 index 00000000000..7c2f13b75a8 --- /dev/null +++ b/reportbuilder/amd/src/local/selectors.js @@ -0,0 +1,34 @@ +// This file is part of Moodle - http://moodle.org/ +// +// Moodle is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Moodle is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Moodle. If not, see . + +/** + * Report builder selectors + * + * @module core_reportbuilder/local/selectors + * @package core_reportbuilder + * @copyright 2021 Paul Holden + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +const SELECTORS = { + regions: { + systemReport: '[data-region="core_reportbuilder/system-report"]', + filtersForm: '[data-region="filters-form"]', + }, +}; + +SELECTORS.forSystemReport = reportId => `${SELECTORS.regions.systemReport}[data-reportid="${reportId}"]`; + +export default SELECTORS; diff --git a/reportbuilder/amd/src/report.js b/reportbuilder/amd/src/report.js new file mode 100644 index 00000000000..681c1ed6fa9 --- /dev/null +++ b/reportbuilder/amd/src/report.js @@ -0,0 +1,49 @@ +// This file is part of Moodle - http://moodle.org/ +// +// Moodle is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Moodle is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Moodle. If not, see . + +/** + * Report builder report management + * + * @module core_reportbuilder/report + * @package core_reportbuilder + * @copyright 2021 Paul Holden + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +import * as reportEvents from 'core_reportbuilder/local/events'; +import * as reportSelectors from 'core_reportbuilder/local/selectors'; +import {setPageNumber, refreshTableContent} from 'core_table/dynamic'; +import * as tableSelectors from 'core_table/local/dynamic/selectors'; + +/** + * Initialise module + * + * @param {Number} reportId + */ +export const init = reportId => { + // Listen for the table reload event. + document.addEventListener(reportEvents.tableReload, async(event) => { + const triggerElement = event.target.closest(reportSelectors.forSystemReport(reportId)); + if (triggerElement === null) { + return; + } + + const tableRoot = triggerElement.querySelector(tableSelectors.main.region); + const pageNumber = event.detail?.preservePagination ? null : 1; + + await setPageNumber(tableRoot, pageNumber, false) + .then(refreshTableContent); + }); +}; diff --git a/reportbuilder/classes/external/system_report_exporter.php b/reportbuilder/classes/external/system_report_exporter.php new file mode 100644 index 00000000000..3f77f92caff --- /dev/null +++ b/reportbuilder/classes/external/system_report_exporter.php @@ -0,0 +1,115 @@ +. + +declare(strict_types=1); + +namespace core_reportbuilder\external; + +use core\external\persistent_exporter; +use core_reportbuilder\form\filter; +use core_reportbuilder\local\models\report; +use core_reportbuilder\local\report\base; +use core_reportbuilder\table\system_report_table; +use core_reportbuilder\table\system_report_table_filterset; +use core_reportbuilder\table\system_report_table_parameters_filter; +use renderer_base; + +/** + * Report exporter class + * + * @package core_reportbuilder + * @copyright 2020 Paul Holden + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class system_report_exporter extends persistent_exporter { + + /** + * Return the name of the class we are exporting + * + * @return string + */ + protected static function define_class(): string { + return report::class; + } + + /** + * Return a list of objects that are related to the persistent + * + * @return array + */ + protected static function define_related(): array { + return [ + 'source' => base::class, + 'parameters' => 'string', + ]; + } + + /** + * Return a list of additional properties used only for display + * + * @return array + */ + protected static function define_other_properties(): array { + return [ + 'table' => ['type' => PARAM_RAW], + 'parameters' => ['type' => PARAM_RAW], + 'filterspresent' => ['type' => PARAM_BOOL], + 'filtersform' => [ + 'type' => PARAM_RAW, + 'optional' => true, + ], + ]; + } + + /** + * Get additional values to inject while exporting + * + * @uses \core_reportbuilder\output\renderer::render_system_report_table() + * + * @param renderer_base $output + * @return array + */ + protected function get_other_values(renderer_base $output): array { + /** @var base $source */ + $source = $this->related['source']; + + /** @var array $parameters */ + $parameters = $this->related['parameters']; + + $filterset = new system_report_table_filterset(); + $filterset->add_filter(new system_report_table_parameters_filter('parameters', null, [$parameters])); + + $table = system_report_table::create($this->persistent->get('id'), (array) json_decode($parameters, true)); + $table->set_filterset($filterset); + + // Generate filters form if report contains any filters. + $filterspresent = !empty($source->get_filters()); + if ($filterspresent) { + $filtersform = new filter(null, null, 'post', '', [], true, [ + 'reportid' => $this->persistent->get('id'), + 'parameters' => $parameters, + ]); + $filtersform->set_data_for_dynamic_submission(); + } + + return [ + 'table' => $output->render($table), + 'parameters' => $this->related['parameters'], + 'filterspresent' => $filterspresent, + 'filtersform' => $filterspresent ? $filtersform->render() : '', + ]; + } +} diff --git a/reportbuilder/classes/form/filter.php b/reportbuilder/classes/form/filter.php new file mode 100644 index 00000000000..1f977dbd74f --- /dev/null +++ b/reportbuilder/classes/form/filter.php @@ -0,0 +1,139 @@ +. + +declare(strict_types=1); + +namespace core_reportbuilder\form; + +use context; +use moodle_url; +use core_form\dynamic_form; +use core_reportbuilder\manager; +use core_reportbuilder\system_report; +use core_reportbuilder\local\models\report; + +/** + * Dynamic filter form + * + * @package core_reportbuilder + * @copyright 2021 Paul Holden + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class filter extends dynamic_form { + + /** + * Return instance of the system report using the filter form + * + * @return system_report + */ + private function get_system_report(): system_report { + $report = new report($this->optional_param('reportid', 0, PARAM_INT)); + $parameters = (array) json_decode($this->optional_param('parameters', '', PARAM_RAW)); + + /** @var system_report $systemreport */ + $systemreport = manager::get_report_from_persistent($report, $parameters); + + return $systemreport; + } + + /** + * Return the context for the form, it should be that of the system report itself + * + * @return context + */ + protected function get_context_for_dynamic_submission(): context { + return ($this->get_system_report())->get_context(); + } + + /** + * Ensure current user is able to use this form + * + * A {@see \core_reportbuilder\report_access_exception} will be thrown if they can't + */ + protected function check_access_for_dynamic_submission(): void { + $this->get_system_report()->require_can_view(); + } + + /** + * Process the form submission + * + * @return bool + */ + public function process_dynamic_submission() { + $values = $this->get_data(); + + // Remove some unneeded fields. + unset($values->reportid, $values->parameters); + + return $this->get_system_report()->set_filter_values((array) $values); + } + + /** + * Load in existing data as form defaults + */ + public function set_data_for_dynamic_submission(): void { + $defaults = [ + 'reportid' => $this->optional_param('reportid', 0, PARAM_INT), + 'parameters' => $this->optional_param('parameters', 0, PARAM_RAW), + ]; + + $this->set_data(array_merge($defaults, $this->get_system_report()->get_filter_values())); + } + + /** + * URL of the page using this form + * + * @return moodle_url + */ + protected function get_page_url_for_dynamic_submission(): moodle_url { + return new moodle_url('/'); + } + + /** + * Filter form definition. It should provide necessary field itself, then allow all report filters to add their own elements + */ + protected function definition() { + global $OUTPUT; + + $mform = $this->_form; + + $mform->addElement('hidden', 'reportid'); + $mform->setType('reportid', PARAM_INT); + + $mform->addElement('hidden', 'parameters'); + $mform->setType('parameters', PARAM_RAW); + + // Allow each filter instance to add itself to this form, wrapping each inside custom header/footer template. + foreach ($this->get_system_report()->get_filter_instances() as $filterinstance) { + $filterinstance->setup_form($mform); + } + + $this->set_display_vertical(); + + // We'll add a second submit button to the form that will be used to reset current report filters. + $mform->registerNoSubmitButton('resetfilters'); + + $buttons = []; + $buttons[] = $mform->createElement('submit', 'submitbutton', get_string('apply', 'core_reportbuilder')); + $buttons[] = $mform->createElement('submit', 'resetfilters', get_string('resetall', 'core_reportbuilder'), + null, null, ['customclassoverride' => 'btn-link']); + + $mform->addGroup($buttons, 'buttonar', '', [' '], false); + $mform->closeHeaderBefore('buttonar'); + + $mform->disable_form_change_checker(); + } +} diff --git a/reportbuilder/classes/local/report/base.php b/reportbuilder/classes/local/report/base.php index d4616d99781..cef89c61efb 100644 --- a/reportbuilder/classes/local/report/base.php +++ b/reportbuilder/classes/local/report/base.php @@ -115,6 +115,13 @@ abstract class base { */ abstract protected function initialise(): void; + /** + * Output the report + * + * @return string + */ + abstract public function output(): string; + /** * Get the report availability. Sub-classes should override this method to declare themselves unavailable, for example if * they require classes that aren't present due to missing plugin diff --git a/reportbuilder/classes/output/renderer.php b/reportbuilder/classes/output/renderer.php new file mode 100644 index 00000000000..b9cdb45873f --- /dev/null +++ b/reportbuilder/classes/output/renderer.php @@ -0,0 +1,59 @@ +. + +declare(strict_types=1); + +namespace core_reportbuilder\output; + +use plugin_renderer_base; +use core_reportbuilder\table\system_report_table; + +/** + * Report renderer class + * + * @package core_reportbuilder + * @copyright 2020 Paul Holden + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class renderer extends plugin_renderer_base { + + /** + * Render a system report + * + * @param system_report $report + * @return string + */ + protected function render_system_report(system_report $report): string { + $context = $report->export_for_template($this); + + return $this->render_from_template('core_reportbuilder/system_report', $context); + } + + /** + * Render a system report table + * + * @param system_report_table $table + * @return string + */ + protected function render_system_report_table(system_report_table $table): string { + ob_start(); + $table->out($table->get_default_per_page(), false); + $output = ob_get_contents(); + ob_end_clean(); + + return $output; + } +} diff --git a/reportbuilder/classes/output/system_report.php b/reportbuilder/classes/output/system_report.php new file mode 100644 index 00000000000..f1468a14e4a --- /dev/null +++ b/reportbuilder/classes/output/system_report.php @@ -0,0 +1,74 @@ +. + +declare(strict_types=1); + +namespace core_reportbuilder\output; + +use renderable; +use renderer_base; +use stdClass; +use templatable; +use core_reportbuilder\external\system_report_exporter; +use core_reportbuilder\local\models\report; +use core_reportbuilder\local\report\base; + +/** + * System report output class + * + * @package core_reportbuilder + * @copyright 2020 Paul Holden + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class system_report implements renderable, templatable { + + /** @var report $report */ + protected $report; + + /** @var base $source */ + protected $source; + + /** @var array $parameters */ + protected $parameters; + + /** + * Class constructor + * + * @param report $report + * @param base $source + * @param array $parameters + */ + public function __construct(report $report, base $source, array $parameters) { + $this->report = $report; + $this->source = $source; + $this->parameters = $parameters; + } + + /** + * Export report data suitable for a template + * + * @param renderer_base $output + * @return stdClass + */ + public function export_for_template(renderer_base $output): stdClass { + $exporter = new system_report_exporter($this->report, [ + 'source' => $this->source, + 'parameters' => json_encode($this->parameters), + ]); + + return $exporter->export($output); + } +} diff --git a/reportbuilder/classes/system_report.php b/reportbuilder/classes/system_report.php index 691e693365b..3c6c67c5269 100644 --- a/reportbuilder/classes/system_report.php +++ b/reportbuilder/classes/system_report.php @@ -180,6 +180,23 @@ abstract class system_report extends base { return clean_param($this->parameters[$param], $type); } + /** + * Output the report + * + * @uses \core_reportbuilder\output\renderer::render_system_report() + * + * @return string + */ + final public function output(): string { + global $PAGE; + + /** @var \core_reportbuilder\output\renderer $renderer */ + $renderer = $PAGE->get_renderer('core_reportbuilder'); + $report = new \core_reportbuilder\output\system_report($this->get_report_persistent(), $this, $this->parameters); + + return $renderer->render($report); + } + /** * CSS classes to add to the row. Can be overridden by system reports do define class to be added to output according to * content of each row diff --git a/reportbuilder/classes/table/system_report_table.php b/reportbuilder/classes/table/system_report_table.php new file mode 100644 index 00000000000..705363509d0 --- /dev/null +++ b/reportbuilder/classes/table/system_report_table.php @@ -0,0 +1,329 @@ +. + +declare(strict_types=1); + +namespace core_reportbuilder\table; + +use context; +use html_writer; +use moodle_exception; +use moodle_url; +use renderable; +use table_sql; +use stdClass; +use core_table\dynamic; +use core_reportbuilder\manager; +use core_reportbuilder\system_report; +use core_reportbuilder\local\filters\base; +use core_reportbuilder\local\models\report; +use core_reportbuilder\local\report\action; +use core_reportbuilder\local\report\column; +use core_table\local\filter\filterset; + +defined('MOODLE_INTERNAL') || die; + +require_once("{$CFG->libdir}/tablelib.php"); + +/** + * System report dynamic table class + * + * @package core_reportbuilder + * @copyright 2020 Paul Holden + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class system_report_table extends table_sql implements dynamic, renderable { + + /** @var string Unique ID prefix for the table */ + private const UNIQUEID_PREFIX = 'system-report-table-'; + + /** @var report $report */ + protected $report; + + /** @var system_report $systemreport */ + protected $systemreport; + + /** + * Table constructor. Note that the passed unique ID value must match the pattern "system-report-table-(\d+)" so that + * dynamic updates continue to load the same report + * + * @param string $uniqueid + * @param array $parameters + * @throws moodle_exception For invalid unique ID + */ + public function __construct(string $uniqueid, array $parameters = []) { + if (!preg_match('/^' . self::UNIQUEID_PREFIX . '(?\d+)$/', $uniqueid, $matches)) { + throw new moodle_exception('invalidsystemreportid', 'core_reportbuilder', '', null, $uniqueid); + } + + parent::__construct($uniqueid); + + // Load the report persistent, and accompanying system report instance. + $this->report = new report($matches['id']); + $this->systemreport = manager::get_report_from_persistent($this->report, $parameters); + + $fields = $this->systemreport->get_base_fields(); + $maintable = $this->systemreport->get_main_table(); + $maintablealias = $this->systemreport->get_main_table_alias(); + $joins = $this->systemreport->get_joins(); + [$where, $params] = $this->systemreport->get_base_condition(); + + $this->set_attribute('data-region', 'reportbuilder-table'); + $this->set_attribute('class', $this->attributes['class'] . ' reportbuilder-table'); + + // Download options. + $this->showdownloadbuttonsat = [TABLE_P_BOTTOM]; + $this->is_downloading($parameters['download'] ?? null, $this->systemreport->get_downloadfilename()); + + // Retrieve all report columns. If we are downloading the report, remove as required. + $columns = $this->systemreport->get_columns(); + if ($this->is_downloading()) { + $columns = array_diff_key($columns, + array_flip($this->systemreport->get_exclude_columns_for_download())); + } + + $columnheaders = []; + $columnindex = 1; + foreach ($columns as $identifier => $column) { + $column->set_index($columnindex++); + + $columnheaders[$column->get_column_alias()] = $column->get_title(); + + // Add each columns fields, joins and params to our report. + $fields = array_merge($fields, $column->get_fields()); + $joins = array_merge($joins, $column->get_joins()); + $params = array_merge($params, $column->get_params()); + + // Disable sorting for some columns. + if (!$column->get_is_sortable()) { + $this->no_sorting($column->get_column_alias()); + } + } + + // If the report has any actions then append appropriate column, note that actions are excluded during download. + if ($this->systemreport->has_actions() && !$this->is_downloading()) { + $columnheaders['actions'] = html_writer::tag('span', get_string('actions', 'core_reportbuilder'), [ + 'class' => 'sr-only', + ]); + $this->no_sorting('actions'); + } + + $this->define_columns(array_keys($columnheaders)); + $this->define_headers(array_values($columnheaders)); + + // Initial table sort column. + if ($sortcolumn = $this->systemreport->get_initial_sort_column()) { + $this->sortable(true, $sortcolumn->get_column_alias(), $this->systemreport->get_initial_sort_direction()); + } + + // Table configuration. + $this->initialbars(false); + $this->collapsible(false); + $this->pageable(true); + $this->set_default_per_page($this->systemreport->get_default_per_page()); + + // Initialise table SQL properties. + $fieldsql = implode(', ', $fields); + $this->init_sql($fieldsql, "{{$maintable}} {$maintablealias}", $joins, $where, $params); + } + + /** + * Return a new instance of the class for given report ID. We include report parameters here so they are present during + * initialisation + * + * @param int $reportid + * @param array $parameters + * @return static + */ + public static function create(int $reportid, array $parameters): self { + return new static(self::UNIQUEID_PREFIX . $reportid, $parameters); + } + + /** + * Set the filterset in the table class. We set the report parameters here so that they are persisted while paging + * + * @param filterset $filterset + */ + public function set_filterset(filterset $filterset): void { + $parameters = $filterset->get_filter('parameters')->current(); + $this->systemreport->set_parameters((array) json_decode($parameters, true)); + + parent::set_filterset($filterset); + } + + /** + * Initialises table SQL properties + * + * @param string $fields + * @param string $from + * @param array $joins + * @param string $where + * @param array $params + */ + protected function init_sql(string $fields, string $from, array $joins, string $where, array $params): void { + $wheres = []; + if ($where !== '') { + $wheres[] = $where; + } + + $filtervalues = $this->systemreport->get_filter_values(); + foreach ($this->systemreport->get_filters() as $filter) { + /** @var base $filterclass */ + $filterclass = $filter->get_filter_class(); + $filterinstance = $filterclass::create($filter); + + [$filtersql, $filterparams] = $filterinstance->get_sql_filter($filtervalues); + if ($filtersql !== '') { + $wheres[] = "({$filtersql})"; + $params = array_merge($params, $filterparams); + + $joins = array_merge($joins, $filter->get_joins()); + } + } + + $wheresql = '1=1'; + if (!empty($wheres)) { + $wheresql = implode(' AND ', $wheres); + } + + // Add unique table joins. + $from .= ' ' . implode(' ', array_unique($joins)); + + $this->set_sql($fields, $from, $wheresql, $params); + $this->set_count_sql("SELECT COUNT(1) FROM {$from} WHERE {$wheresql}", $params); + } + + /** + * Override parent method of the same, to make use of a recordset and avoid issues with duplicate values in the first column + * + * @param int $pagesize + * @param bool $useinitialsbar + */ + public function query_db($pagesize, $useinitialsbar = true) { + global $DB; + + $sql = "SELECT {$this->sql->fields} FROM {$this->sql->from} WHERE {$this->sql->where}"; + + $sort = $this->get_sql_sort(); + if ($sort) { + $sql .= " ORDER BY {$sort}"; + } + + if (!$this->is_downloading()) { + $this->pagesize($pagesize, $DB->count_records_sql($this->countsql, $this->countparams)); + + $this->rawdata = $DB->get_recordset_sql($sql, $this->sql->params, $this->get_page_start(), $this->get_page_size()); + } else { + $this->rawdata = $DB->get_recordset_sql($sql, $this->sql->params); + } + } + + /** + * Override parent method for retrieving row class with that defined by the system report + * + * @param array|stdClass $row + * @return string + */ + public function get_row_class($row) { + return $this->systemreport->get_row_class((object) $row); + } + + /** + * Format each row of returned data, executing defined callbacks for the row and each column + * + * @param array|stdClass $row + * @return array + */ + public function format_row($row) { + $this->systemreport->row_callback((object) $row); + + /** @var column[] $columnsbyalias */ + $columnsbyalias = []; + + // Create a lookup for convenience, indexed by column alias. + $columns = $this->systemreport->get_columns(); + foreach ($columns as $column) { + $columnsbyalias[$column->get_column_alias()] = $column; + } + + // Walk over the row, and for any key that matches one of our column aliases, call that columns format method. + $row = (array) $row; + array_walk($row, static function(&$value, $key) use ($row, $columnsbyalias): void { + if (array_key_exists($key, $columnsbyalias)) { + $value = $columnsbyalias[$key]->format_value($row); + } + }); + + if ($this->systemreport->has_actions()) { + $row['actions'] = $this->format_row_actions((object) $row); + } + + return $row; + } + + /** + * Return formatted actions column for the row + * + * @param stdClass $row + * @return string + */ + private function format_row_actions(stdClass $row): string { + $actions = array_map(static function(action $action) use ($row): string { + return (string) $action->get_action_link($row); + }, $this->systemreport->get_actions()); + + return implode('', $actions); + } + + /** + * Get the context for the table (that of the report persistent) + * + * @return context + */ + public function get_context(): context { + return $this->report->get_context(); + } + + /** + * Set the base URL of the table to the current page URL + */ + public function guess_base_url(): void { + $this->baseurl = new moodle_url('/'); + } + + /** + * Get the html for the download buttons + * + * @return string + */ + public function download_buttons(): string { + global $OUTPUT; + + if ($this->systemreport->can_be_downloaded() && !$this->is_downloading()) { + return $OUTPUT->download_dataformat_selector( + get_string('downloadas', 'table'), + new \moodle_url('/reportbuilder/download.php'), + 'download', + [ + 'id' => $this->report->get('id'), + 'parameters' => json_encode($this->systemreport->get_parameters()), + ] + ); + } + + return ''; + } +} diff --git a/reportbuilder/classes/table/system_report_table_filterset.php b/reportbuilder/classes/table/system_report_table_filterset.php new file mode 100644 index 00000000000..a67348182f8 --- /dev/null +++ b/reportbuilder/classes/table/system_report_table_filterset.php @@ -0,0 +1,42 @@ +. + +declare(strict_types=1); + +namespace core_reportbuilder\table; + +use core_table\local\filter\filterset; + +/** + * System report dynamic table filterset class + * + * @package core_reportbuilder + * @copyright 2020 Paul Holden + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class system_report_table_filterset extends filterset { + + /** + * Get the required filters + * + * @return array. + */ + public function get_required_filters(): array { + return [ + 'parameters' => system_report_table_parameters_filter::class, + ]; + } +} diff --git a/reportbuilder/classes/table/system_report_table_parameters_filter.php b/reportbuilder/classes/table/system_report_table_parameters_filter.php new file mode 100644 index 00000000000..5d934374d79 --- /dev/null +++ b/reportbuilder/classes/table/system_report_table_parameters_filter.php @@ -0,0 +1,32 @@ +. + +declare(strict_types=1); + +namespace core_reportbuilder\table; + +use core_table\local\filter\string_filter; + +/** + * Class for storing given system report parameters + * + * @package core_reportbuilder + * @copyright 2021 Paul Holden + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class system_report_table_parameters_filter extends string_filter { + +} diff --git a/reportbuilder/download.php b/reportbuilder/download.php new file mode 100644 index 00000000000..503960de3dc --- /dev/null +++ b/reportbuilder/download.php @@ -0,0 +1,55 @@ +. + +/** + * Download a report + * + * @package core_reportbuilder + * @copyright 2021 David Matamoros + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +declare(strict_types=1); + +use core_reportbuilder\system_report_factory; + +require_once(__DIR__ . '/../config.php'); + +require_login(); + +$reportid = required_param('id', PARAM_INT); +$download = required_param('download', PARAM_ALPHA); +$parameters = optional_param('parameters', null, PARAM_RAW); + +$reportpersistent = new \core_reportbuilder\local\models\report($reportid); +$context = $reportpersistent->get_context(); + +$PAGE->set_context($context); +$PAGE->set_url(new moodle_url('/reportbuilder/download.php')); + +$systemreport = system_report_factory::create($reportpersistent->get('source'), $context); +if (!$systemreport->can_be_downloaded()) { + throw new \core_reportbuilder\report_access_exception(); +} + +// Combine original report parameters with 'download' parameter. +$reportparameters = ['download' => $download]; +if ($parameters) { + $reportparameters = array_merge($reportparameters, (array) json_decode($parameters)); +} + +$outputreport = new \core_reportbuilder\output\system_report($reportpersistent, $systemreport, $reportparameters); +echo $PAGE->get_renderer('core_reportbuilder')->render($outputreport); diff --git a/reportbuilder/templates/system_report.mustache b/reportbuilder/templates/system_report.mustache new file mode 100644 index 00000000000..c89841e45a3 --- /dev/null +++ b/reportbuilder/templates/system_report.mustache @@ -0,0 +1,46 @@ +{{! + This file is part of Moodle - http://moodle.org/ + + Moodle is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Moodle is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Moodle. If not, see . +}} +{{! + @template core_reportbuilder/system_report + + Template for a system report + + Example context (json): + { + "id": 1, + "source": "some\\class\\name", + "parameters": [], + "table": "table", + "filterspresent": true, + "filtersform": "form" + } +}} +
+
+ + {{{table}}} +
+
+ +{{#js}} + require(['core_reportbuilder/report'], function(report) { + report.init('{{id}}'); + }); +{{/js}} diff --git a/reportbuilder/tests/external/system_report_exporter_test.php b/reportbuilder/tests/external/system_report_exporter_test.php new file mode 100644 index 00000000000..b0e730349bf --- /dev/null +++ b/reportbuilder/tests/external/system_report_exporter_test.php @@ -0,0 +1,94 @@ +. + +declare(strict_types=1); + +namespace core_reportbuilder\external; + +use advanced_testcase; +use context_system; +use moodle_url; +use core_reportbuilder\system_report_available; +use core_reportbuilder\system_report_factory; + +/** + * Unit tests for system report exporter + * + * @package core_reportbuilder + * @covers \core_reportbuilder\external\system_report_exporter + * @copyright 2021 Paul Holden + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class system_report_exporter_testcase extends advanced_testcase { + + /** + * Load test fixture + */ + public static function setUpBeforeClass(): void { + global $CFG; + + require_once("{$CFG->dirroot}/reportbuilder/tests/fixtures/system_report_available.php"); + } + + /** + * Data provider for {@see test_export} + * + * @return array[] + */ + public function export_provider(): array { + return [ + ['With filters' => true], + ['Without filters' => false], + ]; + } + + /** + * Text execute method + * + * @param bool $withfilters + * + * @dataProvider export_provider + */ + public function test_export(bool $withfilters): void { + global $PAGE; + + $this->resetAfterTest(); + + // Prevent debug warnings from flexible_table. + $PAGE->set_url(new moodle_url('/')); + + $systemreport = system_report_factory::create(system_report_available::class, context_system::instance(), '', '', 0, + ['withfilters' => $withfilters]); + + $exporter = new system_report_exporter($systemreport->get_report_persistent(), [ + 'source' => $systemreport, + 'parameters' => json_encode($systemreport->get_parameters()), + ]); + + $data = $exporter->export($PAGE->get_renderer('core_reportbuilder')); + $this->assertNotEmpty($data->table); + + if ($withfilters) { + $this->assertEquals('{"withfilters":true}', $data->parameters); + $this->assertTrue($data->filterspresent); + $this->assertNotEmpty($data->filtersform); + } else { + $this->assertEquals('{"withfilters":false}', $data->parameters); + $this->assertFalse($data->filterspresent); + $this->assertEmpty($data->filtersform); + } + } +} diff --git a/reportbuilder/tests/fixtures/system_report_available.php b/reportbuilder/tests/fixtures/system_report_available.php new file mode 100644 index 00000000000..f0f4591c291 --- /dev/null +++ b/reportbuilder/tests/fixtures/system_report_available.php @@ -0,0 +1,95 @@ +. + +declare(strict_types=1); + +namespace core_reportbuilder; + +use core_reportbuilder\local\report\action; +use lang_string; +use core_reportbuilder\local\filters\text; +use core_reportbuilder\local\report\column; +use core_reportbuilder\local\report\filter; +use moodle_url; +use pix_icon; + +/** + * Testable system report fixture + * + * @package core_reportbuilder + * @copyright 2020 Paul Holden + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class system_report_available extends system_report { + + /** + * Initialise the report + */ + protected function initialise(): void { + $this->set_main_table('user', 'u'); + $this->annotate_entity('user', new lang_string('user')); + + $this->add_column((new column( + 'username', + new lang_string('username'), + 'user' + )) + ->add_joins($this->get_joins()) + ->add_field('u.firstname') + ); + + $withfilters = $this->get_parameter('withfilters', false, PARAM_BOOL); + if ($withfilters) { + $this->add_filter((new filter( + text::class, + 'username', + new lang_string('username'), + 'user', + 'u.username' + )) + ->add_joins($this->get_joins()) + ); + } + + $withactions = $this->get_parameter('withactions', false, PARAM_BOOL); + if ($withactions) { + $this->add_action(new action( + new moodle_url('/user/profile.php', ['id' => ':id']), + new pix_icon('e/search', get_string('view')), + [], + true, + )); + } + } + + /** + * Ensure we can view the report + * + * @return bool + */ + protected function can_view(): bool { + return true; + } + + /** + * Explicitly set availability of report + * + * @return bool + */ + public static function is_available(): bool { + return true; + } +} diff --git a/reportbuilder/tests/fixtures/system_report_unavailable.php b/reportbuilder/tests/fixtures/system_report_unavailable.php new file mode 100644 index 00000000000..435445c02c6 --- /dev/null +++ b/reportbuilder/tests/fixtures/system_report_unavailable.php @@ -0,0 +1,42 @@ +. + +declare(strict_types=1); + +namespace core_reportbuilder; + +defined('MOODLE_INTERNAL') || die(); + +require_once(__DIR__ . '/system_report_available.php'); + +/** + * Testable unavailable system report fixture + * + * @package core_reportbuilder + * @copyright 2020 Paul Holden + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class system_report_unavailable extends system_report_available { + + /** + * Explicitly set availability of report to false + * + * @return bool + */ + public static function is_available(): bool { + return false; + } +} diff --git a/reportbuilder/tests/fixtures/testable_system_report_table.php b/reportbuilder/tests/fixtures/testable_system_report_table.php new file mode 100644 index 00000000000..f6f76ee5e4d --- /dev/null +++ b/reportbuilder/tests/fixtures/testable_system_report_table.php @@ -0,0 +1,75 @@ +. + +declare(strict_types=1); + +namespace core_reportbuilder; + +use stdClass; +use core_reportbuilder\table\system_report_table; + +/** + * Testable system report table for getting report data + * + * @package core_reportbuilder + * @copyright 2021 Paul Holden + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class testable_system_report_table extends system_report_table { + + /** + * Format each row of returned data, replacing aliased column names with the original + * + * @param array|stdClass $row + * @return array + */ + public function format_row($row): array { + $record = parent::format_row($row); + $result = []; + + $columns = $this->systemreport->get_columns(); + foreach ($columns as $column) { + $result[$column->get_name()] = $record[$column->get_column_alias()]; + } + + return $result; + } + + /** + * Return all table rows + * + * @return array + */ + public function get_table_rows(): array { + global $PAGE; + + $PAGE->set_url('/'); + + $result = []; + + $this->guess_base_url(); + $this->setup(); + + $this->query_db(0, false); + foreach ($this->rawdata as $record) { + $result[] = $this->format_row($record); + } + + $this->close_recordset(); + + return $result; + } +} diff --git a/reportbuilder/tests/local/report/base_test.php b/reportbuilder/tests/local/report/base_test.php new file mode 100644 index 00000000000..88be3775556 --- /dev/null +++ b/reportbuilder/tests/local/report/base_test.php @@ -0,0 +1,153 @@ +. + +declare(strict_types=1); + +namespace core_reportbuilder\local\report; + +use advanced_testcase; +use context_system; +use core_reportbuilder\system_report_available; +use core_reportbuilder\system_report_factory; + +/** + * Unit tests for report base class + * + * @package core_reportbuilder + * @covers \core_reportbuilder\local\report\base + * @copyright 2021 David Matamoros + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class base_testcase extends advanced_testcase { + + /** + * Load required class + */ + public static function setUpBeforeClass(): void { + global $CFG; + require_once("{$CFG->dirroot}/reportbuilder/tests/fixtures/system_report_available.php"); + } + + /** + * Test for add_base_condition_simple + */ + public function test_add_base_condition_simple(): void { + $this->resetAfterTest(); + + $systemreport = system_report_factory::create(system_report_available::class, context_system::instance()); + $systemreport->add_base_condition_simple('username', 'admin'); + [$where, $params] = $systemreport->get_base_condition(); + $this->assertStringMatchesFormat('username = :%a', $where); + $this->assertEqualsCanonicalizing(['admin'], $params); + } + + /** + * Test for add_base_condition_simple null + */ + public function test_add_base_condition_simple_null(): void { + $this->resetAfterTest(); + + $systemreport = system_report_factory::create(system_report_available::class, context_system::instance()); + $systemreport->add_base_condition_simple('username', null); + [$where, $params] = $systemreport->get_base_condition(); + $this->assertEquals('username IS NULL', $where); + $this->assertEmpty($params); + } + + /** + * Test for get_filter_instances + */ + public function test_get_filter_instances(): void { + $this->resetAfterTest(); + + $systemreport = system_report_factory::create(system_report_available::class, context_system::instance(), + '', '', 0, ['withfilters' => true]); + $filters = $systemreport->get_filter_instances(); + $this->assertCount(1, $filters); + $this->assertInstanceOf(\core_reportbuilder\local\filters\text::class, reset($filters)); + } + + /** + * Test for set_downloadable + */ + public function test_set_downloadable(): void { + $this->resetAfterTest(); + + $systemreport = system_report_factory::create(system_report_available::class, context_system::instance()); + $systemreport->set_downloadable(true, 'testfilename'); + $this->assertTrue($systemreport->is_downloadable()); + $this->assertEquals('testfilename', $systemreport->get_downloadfilename()); + + $systemreport->set_downloadable(false, 'anothertestfilename'); + $this->assertFalse($systemreport->is_downloadable()); + $this->assertEquals('anothertestfilename', $systemreport->get_downloadfilename()); + } + + /** + * Test for get_context + */ + public function test_get_context(): void { + $this->resetAfterTest(); + + $systemreport = system_report_factory::create(system_report_available::class, context_system::instance()); + $this->assertEquals(context_system::instance(), $systemreport->get_context()); + + $course = $this->getDataGenerator()->create_course(); + $contextcourse = \context_course::instance($course->id); + $systemreport2 = system_report_factory::create(system_report_available::class, $contextcourse); + $this->assertEquals($contextcourse, $systemreport2->get_context()); + } + + /** + * Test for get_column + */ + public function test_get_column(): void { + $this->resetAfterTest(); + + $systemreport = system_report_factory::create(system_report_available::class, context_system::instance()); + $column = $systemreport->get_column('user:username'); + $this->assertInstanceOf(column::class, $column); + + $column = $systemreport->get_column('user:nonexistingcolumn'); + $this->assertNull($column); + } + + /** + * Test for get_filter + */ + public function test_get_filter(): void { + $this->resetAfterTest(); + + $systemreport = system_report_factory::create(system_report_available::class, context_system::instance(), + '', '', 0, ['withfilters' => true]); + $filter = $systemreport->get_filter('user:username'); + $this->assertInstanceOf(filter::class, $filter); + + $filter = $systemreport->get_filter('user:nonexistingfilter'); + $this->assertNull($filter); + } + + /** + * Test for get_report_persistent + */ + public function test_get_report_persistent(): void { + $this->resetAfterTest(); + + $systemreport = system_report_factory::create(system_report_available::class, context_system::instance()); + $persistent = $systemreport->get_report_persistent(); + $this->assertEquals(system_report_available::class, $persistent->get('source')); + } +} diff --git a/reportbuilder/tests/manager_test.php b/reportbuilder/tests/manager_test.php new file mode 100644 index 00000000000..03e85bc6e3c --- /dev/null +++ b/reportbuilder/tests/manager_test.php @@ -0,0 +1,132 @@ +. + +declare(strict_types=1); + +namespace core_reportbuilder; + +use advanced_testcase; +use context_system; +use stdClass; +use core_reportbuilder\local\models\report; +use core_reportbuilder\local\report\base; + +/** + * Unit tests for the report manager class + * + * @package core_reportbuilder + * @covers \core_reportbuilder\manager + * @copyright 2020 Paul Holden + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class manager_testcase extends advanced_testcase { + + /** + * Test creating a report instance from persistent + */ + public function test_get_report_from_persistent(): void { + global $CFG; + require_once("{$CFG->dirroot}/reportbuilder/tests/fixtures/system_report_available.php"); + + $this->resetAfterTest(); + + $report = manager::create_report_persistent((object) [ + 'type' => base::TYPE_SYSTEM_REPORT, + 'source' => system_report_available::class, + ]); + + $systemreport = manager::get_report_from_persistent($report); + $this->assertInstanceOf(system_report::class, $systemreport); + } + + /** + * Test creating a report instance from persistent with an invalid source + */ + public function test_get_report_from_persistent_invalid(): void { + $this->resetAfterTest(); + + $report = manager::create_report_persistent((object) [ + 'type' => base::TYPE_SYSTEM_REPORT, + 'source' => stdClass::class, + ]); + + $this->expectException(source_invalid_exception::class); + manager::get_report_from_persistent($report); + } + + /** + * Test creating a report instance from persistent with an unavailable source + */ + public function test_get_report_from_persistent_unavailable(): void { + global $CFG; + require_once("{$CFG->dirroot}/reportbuilder/tests/fixtures/system_report_unavailable.php"); + + $this->resetAfterTest(); + + $report = manager::create_report_persistent((object) [ + 'type' => base::TYPE_SYSTEM_REPORT, + 'source' => system_report_unavailable::class, + ]); + + $this->expectException(source_unavailable_exception::class); + manager::get_report_from_persistent($report); + } + + /** + * Test report source exists + */ + public function test_report_source_exists(): void { + global $CFG; + + require_once("{$CFG->dirroot}/reportbuilder/tests/fixtures/system_report_available.php"); + $this->assertTrue(manager::report_source_exists(system_report_available::class)); + + $this->assertFalse(manager::report_source_exists(stdClass::class)); + } + + /** + * Test report source available + */ + public function test_report_source_available(): void { + global $CFG; + + require_once("{$CFG->dirroot}/reportbuilder/tests/fixtures/system_report_available.php"); + $this->assertTrue(manager::report_source_available(system_report_available::class)); + + require_once("{$CFG->dirroot}/reportbuilder/tests/fixtures/system_report_unavailable.php"); + $this->assertFalse(manager::report_source_available(system_report_unavailable::class)); + } + + /** + * Test creating a report persistent model + */ + public function test_create_report_persistent(): void { + global $CFG; + require_once("{$CFG->dirroot}/reportbuilder/tests/fixtures/system_report_available.php"); + + $this->resetAfterTest(); + + $report = manager::create_report_persistent((object) [ + 'type' => base::TYPE_SYSTEM_REPORT, + 'source' => \core_reportbuilder\system_report_available::class, + ]); + + $this->assertInstanceOf(report::class, $report); + $this->assertEquals(base::TYPE_SYSTEM_REPORT, $report->get('type')); + $this->assertEquals(system_report_available::class, $report->get('source')); + $this->assertInstanceOf(context_system::class, $report->get_context()); + } +} diff --git a/reportbuilder/tests/system_report_factory_test.php b/reportbuilder/tests/system_report_factory_test.php new file mode 100644 index 00000000000..3adfd069ff9 --- /dev/null +++ b/reportbuilder/tests/system_report_factory_test.php @@ -0,0 +1,85 @@ +. + +declare(strict_types=1); + +namespace core_reportbuilder; + +use advanced_testcase; +use context_system; +use stdClass; + +/** + * Unit tests for the system report factory class + * + * @package core_reportbuilder + * @covers \core_reportbuilder\system_report_factory + * @copyright 2020 Paul Holden + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class system_report_factory_testcase extends advanced_testcase { + + /** + * Test creating a valid/available system report + */ + public function test_create(): void { + global $CFG; + require_once("{$CFG->dirroot}/reportbuilder/tests/fixtures/system_report_available.php"); + + $this->resetAfterTest(); + + $systemreport = system_report_factory::create(system_report_available::class, context_system::instance()); + $this->assertInstanceOf(system_report::class, $systemreport); + } + + /** + * Test that creating a system report returns the same persistent if it already exists + */ + public function test_create_previously_exists(): void { + global $CFG; + require_once("{$CFG->dirroot}/reportbuilder/tests/fixtures/system_report_available.php"); + + $this->resetAfterTest(); + + $systemreportone = system_report_factory::create(system_report_available::class, context_system::instance()); + $systemreporttwo = system_report_factory::create(system_report_available::class, context_system::instance()); + + // Assert we have the same persistent for each. + $this->assertEquals($systemreportone->get_report_persistent()->get('id'), + $systemreporttwo->get_report_persistent()->get('id')); + } + + /** + * Test creating an system report with an invalid source + */ + public function test_create_invalid(): void { + $this->expectException(source_invalid_exception::class); + system_report_factory::create(stdClass::class, context_system::instance()); + } + + /** + * Test creating an unavailable system report + */ + public function test_create_unavailable(): void { + global $CFG; + require_once("{$CFG->dirroot}/reportbuilder/tests/fixtures/system_report_unavailable.php"); + + $this->resetAfterTest(); + + $this->expectException(source_unavailable_exception::class); + system_report_factory::create(system_report_unavailable::class, context_system::instance()); + } +} diff --git a/reportbuilder/tests/system_report_test.php b/reportbuilder/tests/system_report_test.php new file mode 100644 index 00000000000..577749101b7 --- /dev/null +++ b/reportbuilder/tests/system_report_test.php @@ -0,0 +1,94 @@ +. + +declare(strict_types=1); + +namespace core_reportbuilder; + +use advanced_testcase; +use context_system; +use core_reportbuilder\local\report\action; + +/** + * Unit tests for the system report class + * + * @package core_reportbuilder + * @covers \core_reportbuilder\system_report + * @copyright 2021 David Matamoros + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class system_report_testcase extends advanced_testcase { + /** + * Test for actions + */ + public function test_actions(): void { + global $CFG; + require_once("{$CFG->dirroot}/reportbuilder/tests/fixtures/system_report_available.php"); + + $this->resetAfterTest(); + + $systemreport = system_report_factory::create(system_report_available::class, context_system::instance(), + '', '', 0, ['withactions' => true]); + $actions = $systemreport->get_actions(); + $this->assertCount(1, $actions); + $action = reset($actions); + $this->assertInstanceOf(action::class, $action); + + $systemreport->add_action($action); + $actions = $systemreport->get_actions(); + $this->assertCount(2, $actions); + $this->assertTrue($systemreport->has_actions()); + } + + /** + * Test for parameters + */ + public function test_parameters(): void { + global $CFG; + require_once("{$CFG->dirroot}/reportbuilder/tests/fixtures/system_report_available.php"); + + $this->resetAfterTest(); + + $systemreport = system_report_factory::create(system_report_available::class, context_system::instance(), + '', '', 0, ['withactions' => true]); + + $this->assertEquals(['withactions' => true], $systemreport->get_parameters()); + + $systemreport->set_parameters(['withfilters' => true, 'secondparameter' => false]); + $this->assertEquals(['withfilters' => true, 'secondparameter' => false], $systemreport->get_parameters()); + + $this->assertFalse((bool)$systemreport->get_parameter('secondparameter', true, PARAM_BOOL)); + // Get a param that does not exist. + $this->assertEquals('3', $systemreport->get_parameter('thirdparameter', '3', PARAM_INT)); + } + + /** + * Test for column sorting + */ + public function test_initial_sort_column(): void { + global $CFG; + require_once("{$CFG->dirroot}/reportbuilder/tests/fixtures/system_report_available.php"); + + $this->resetAfterTest(); + + $systemreport = system_report_factory::create(system_report_available::class, context_system::instance()); + $systemreport->set_initial_sort_column('user:username', SORT_DESC); + $column = $systemreport->get_column('user:username'); + + $this->assertEquals($column, $systemreport->get_initial_sort_column()); + $this->assertEquals(SORT_DESC, $systemreport->get_initial_sort_direction()); + } +} From 7a63ff9f0b1a6ff43621ad57688c01cb40b01d79 Mon Sep 17 00:00:00 2001 From: Sara Arjona Date: Fri, 12 Feb 2021 15:55:15 +0100 Subject: [PATCH 6/9] MDL-70794 reportbuilder: introduce the concept of report entities. Entities are re-usable collections of report columns and filters. When creating system reports, we can re-use those elements from entities without having to know specific details about their implementation. They can be joined to reports, or other entities, using standard SQL query syntax. Define base classes, and create two example entities: course and user. Co-Authored-By: David Matamoros --- lang/en/reportbuilder.php | 10 + reportbuilder/classes/local/entities/base.php | 273 ++++++++++ .../classes/local/entities/course.php | 464 +++++++++++++++++ reportbuilder/classes/local/entities/user.php | 396 +++++++++++++++ .../classes/local/helpers/custom_fields.php | 296 +++++++++++ .../local/helpers/user_profile_fields.php | 242 +++++++++ reportbuilder/classes/local/report/base.php | 55 ++ .../tests/fixtures/course_entity_report.php | 76 +++ .../tests/fixtures/user_entity_report.php | 71 +++ .../tests/local/entities/course_test.php | 475 ++++++++++++++++++ .../tests/local/entities/user_test.php | 290 +++++++++++ .../local/helpers/custom_fields_test.php | 133 +++++ .../helpers/user_profile_fields_test.php | 117 +++++ 13 files changed, 2898 insertions(+) create mode 100644 reportbuilder/classes/local/entities/base.php create mode 100644 reportbuilder/classes/local/entities/course.php create mode 100644 reportbuilder/classes/local/entities/user.php create mode 100644 reportbuilder/classes/local/helpers/custom_fields.php create mode 100644 reportbuilder/classes/local/helpers/user_profile_fields.php create mode 100644 reportbuilder/tests/fixtures/course_entity_report.php create mode 100644 reportbuilder/tests/fixtures/user_entity_report.php create mode 100644 reportbuilder/tests/local/entities/course_test.php create mode 100644 reportbuilder/tests/local/entities/user_test.php create mode 100644 reportbuilder/tests/local/helpers/custom_fields_test.php create mode 100644 reportbuilder/tests/local/helpers/user_profile_fields_test.php diff --git a/lang/en/reportbuilder.php b/lang/en/reportbuilder.php index 34f759cbbae..0aab51ba3df 100644 --- a/lang/en/reportbuilder.php +++ b/lang/en/reportbuilder.php @@ -24,6 +24,12 @@ $string['actions'] = 'Actions'; $string['apply'] = 'Apply'; +$string['coursefullnamewithlink'] = 'Course full name with link'; +$string['courseidnumberewithlink'] = 'Course ID number with link'; +$string['courseshortnamewithlink'] = 'Course short name with link'; +$string['customfieldcolumn'] = '{$a}'; +$string['entitycourse'] = 'Course'; +$string['entityuser'] = 'User'; $string['errorreportaccess'] = 'You can not view this report'; $string['errorsourceinvalid'] = 'Could not find valid report source'; $string['errorsourceunavailable'] = 'Report source is not available'; @@ -50,3 +56,7 @@ $string['filtersreset'] = 'Filters reset'; $string['filterstartswith'] = 'Starts with'; $string['resetall'] = 'Reset all'; $string['selectcourses'] = 'Select courses'; +$string['userfullnamewithlink'] = 'Full name with link'; +$string['userfullnamewithpicture'] = 'Full name with picture'; +$string['userfullnamewithpicturelink'] = 'Full name with picture and link'; +$string['userpicture'] = 'User picture'; diff --git a/reportbuilder/classes/local/entities/base.php b/reportbuilder/classes/local/entities/base.php new file mode 100644 index 00000000000..a91ed7b2a96 --- /dev/null +++ b/reportbuilder/classes/local/entities/base.php @@ -0,0 +1,273 @@ +. + +declare(strict_types=1); + +namespace core_reportbuilder\local\entities; + +use coding_exception; +use lang_string; +use core_reportbuilder\local\report\column; +use core_reportbuilder\local\report\filter; + +/** + * Base class for all report entities + * + * @package core_reportbuilder + * @copyright 2019 Marina Glancy + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +abstract class base { + + /** @var string $entityname Internal reference to name of entity */ + private $entityname = ''; + + /** @var lang_string $entitytitle Used as a title for the entity in reports */ + private $entitytitle = null; + + /** @var array $tablealiases Database tables that this entity uses and their default aliases */ + private $tablealiases = []; + + /** @var string[] $joins List of SQL joins for the entity */ + private $joins = []; + + /** @var column[] $columns List of columns for the entity */ + private $columns = []; + + /** @var filter[] $filters List of filters for the entity */ + private $filters = []; + + /** + * Database tables that this entity uses and their default aliases + * + * Must be overridden by the entity to list all database tables that it expects to be present in the main + * SQL or in JOINs added to this entity + * + * @return string[] Array of $tablename => $alias + */ + abstract protected function get_default_table_aliases(): array; + + /** + * The default title for this entity + * + * @return lang_string + */ + abstract protected function get_default_entity_title(): lang_string; + + /** + * Initialise the entity, called automatically when it is added to a report + * + * This is where entity defines all its columns and filters by calling: + * - {@see add_column} + * - {@see add_filter} + * - etc + * + * @return self + */ + abstract public function initialise(): self; + + /** + * The default machine-readable name for this entity that will be used in the internal names of the columns/filters + * + * @return string + */ + protected function get_default_entity_name(): string { + $namespace = explode('\\', get_called_class()); + + return end($namespace); + } + + /** + * Set entity name + * + * @param string $entityname + * @return self + * @throws coding_exception + */ + final public function set_entity_name(string $entityname): self { + if ($entityname === '' || $entityname !== clean_param($entityname, PARAM_ALPHANUMEXT)) { + throw new coding_exception('Entity name must be comprised of alphanumeric character, underscore or dash'); + } + + $this->entityname = $entityname; + return $this; + } + + /** + * Return entity name + * + * @return string + */ + final public function get_entity_name(): string { + return $this->entityname ?: $this->get_default_entity_name(); + } + + /** + * Set entity title + * + * @param lang_string $title + * @return self + */ + final public function set_entity_title(lang_string $title): self { + $this->entitytitle = $title; + return $this; + } + + /** + * Get entity title + * + * @return lang_string + */ + final public function get_entity_title(): lang_string { + return $this->entitytitle ?? $this->get_default_entity_title(); + } + + /** + * Override the default alias for given database table used in entity queries + * + * @param string $tablename + * @param string $alias + * @return self + * @throws coding_exception + */ + final public function set_table_alias(string $tablename, string $alias): self { + if (!array_key_exists($tablename, $this->get_default_table_aliases())) { + throw new coding_exception('Invalid table name', $tablename); + } + + $this->tablealiases[$tablename] = $alias; + return $this; + } + + /** + * Returns an alias used in the queries for a given table + * + * @param string $tablename + * @return string + * @throws coding_exception + */ + final public function get_table_alias(string $tablename): string { + $defaulttablealiases = $this->get_default_table_aliases(); + if (!array_key_exists($tablename, $defaulttablealiases)) { + throw new coding_exception('Invalid table name', $tablename); + } + + return $this->tablealiases[$tablename] ?? $defaulttablealiases[$tablename]; + } + + /** + * Add join clause required for this entity to join to existing tables/entities + * + * @param string $join + * @return self + */ + final public function add_join(string $join): self { + $this->joins[trim($join)] = trim($join); + return $this; + } + + /** + * Add multiple join clauses required for this entity {@see add_join} + * + * @param string[] $joins + * @return self + */ + final public function add_joins(array $joins): self { + foreach ($joins as $join) { + $this->add_join($join); + } + return $this; + } + + /** + * Return entity joins + * + * @return string[] + */ + final protected function get_joins(): array { + return array_values($this->joins); + } + + /** + * Add a column to the entity + * + * @param column $column + * @return self + */ + final protected function add_column(column $column): self { + $this->columns[$column->get_name()] = $column; + return $this; + } + + /** + * Returns entity columns + * + * @return column[] + */ + final public function get_columns(): array { + return $this->columns; + } + + /** + * Returns an entity column + * + * @param string $name + * @return column + * @throws coding_exception For invalid column name + */ + final public function get_column(string $name): column { + if (!array_key_exists($name, $this->columns)) { + throw new coding_exception('Invalid column name', $name); + } + + return $this->columns[$name]; + } + + /** + * Add a filter to the entity + * + * @param filter $filter + * @return $this + */ + final protected function add_filter(filter $filter): self { + $this->filters[$filter->get_name()] = $filter; + return $this; + } + + /** + * Returns entity filters + * + * @return filter[] + */ + final public function get_filters(): array { + return $this->filters; + } + + /** + * Returns an entity filter + * + * @param string $name + * @return filter + * @throws coding_exception For invalid filter name + */ + final public function get_filter(string $name): filter { + if (!array_key_exists($name, $this->filters)) { + throw new coding_exception('Invalid filter name', $name); + } + + return $this->filters[$name]; + } +} diff --git a/reportbuilder/classes/local/entities/course.php b/reportbuilder/classes/local/entities/course.php new file mode 100644 index 00000000000..dd0989f2f79 --- /dev/null +++ b/reportbuilder/classes/local/entities/course.php @@ -0,0 +1,464 @@ +. + +declare(strict_types=1); + +namespace core_reportbuilder\local\entities; + +use context_course; +use context_helper; +use core_course_category; +use core_reportbuilder\local\filters\boolean_select; +use core_reportbuilder\local\filters\course_selector; +use core_reportbuilder\local\filters\date; +use core_reportbuilder\local\filters\select; +use core_reportbuilder\local\filters\text; +use core_reportbuilder\local\helpers\custom_fields; +use core_reportbuilder\local\helpers\format; +use core_reportbuilder\local\report\column; +use core_reportbuilder\local\report\filter; +use html_writer; +use lang_string; +use stdClass; + +defined('MOODLE_INTERNAL') || die(); + +global $CFG; +require_once($CFG->dirroot . '/course/lib.php'); + +/** + * Course entity class implementation + * + * This entity defines all the course columns and filters to be used in any report. + * + * @package core_reportbuilder + * @copyright 2021 Sara Arjona based on Marina Glancy code. + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class course extends base { + + /** + * Database tables that this entity uses and their default aliases. + * + * @return array + */ + protected function get_default_table_aliases(): array { + return [ + 'course' => 'c', + 'context' => 'cctx', + ]; + } + + /** + * The default machine-readable name for this entity that will be used in the internal names of the columns/filters. + * + * @return string + */ + protected function get_default_entity_name(): string { + return 'course'; + } + + /** + * The default title for this entity in the list of columns/filters in the report builder. + * + * @return lang_string + */ + protected function get_default_entity_title(): lang_string { + return new lang_string('entitycourse', 'core_reportbuilder'); + } + + /** + * Get custom fields helper + * + * @return custom_fields + */ + protected function get_custom_fields(): custom_fields { + $customfields = new custom_fields($this->get_table_alias('course') . '.id', $this->get_entity_name(), + 'core_course', 'course'); + $customfields->add_joins($this->get_joins()); + return $customfields; + } + + /** + * Initialise the entity, adding all course and custom course fields + * + * @return base + */ + public function initialise(): base { + $customfields = $this->get_custom_fields(); + + $columns = array_merge($this->get_all_columns(), $customfields->get_columns()); + foreach ($columns as $column) { + $this->add_column($column); + } + + $filters = array_merge($this->get_all_filters(), $customfields->get_filters()); + foreach ($filters as $filter) { + $this->add_filter($filter); + } + + return $this; + } + + /** + * Course fields. + * + * @return array + */ + protected function get_course_fields(): array { + return [ + 'fullname' => new lang_string('fullnamecourse'), + 'shortname' => new lang_string('shortnamecourse'), + 'category' => new lang_string('coursecategory'), + 'idnumber' => new lang_string('idnumbercourse'), + 'summary' => new lang_string('coursesummary'), + 'format' => new lang_string('format'), + 'startdate' => new lang_string('startdate'), + 'enddate' => new lang_string('enddate'), + 'visible' => new lang_string('coursevisibility'), + 'groupmode' => new lang_string('groupmode', 'group'), + 'groupmodeforce' => new lang_string('groupmodeforce', 'group'), + 'lang' => new lang_string('forcelanguage'), + 'calendartype' => new lang_string('forcecalendartype', 'calendar'), + 'theme' => new lang_string('forcetheme'), + 'enablecompletion' => new lang_string('enablecompletion', 'completion'), + 'downloadcontent' => new lang_string('downloadcoursecontent', 'course'), + ]; + } + + /** + * Check if this field is sortable + * + * @param string $fieldname + * @return bool + */ + protected function is_sortable(string $fieldname): bool { + // Some columns can't be sorted, like longtext or images. + $nonsortable = [ + 'summary', + ]; + + return !in_array($fieldname, $nonsortable); + } + + /** + * Return appropriate column type for given user field + * + * @param string $coursefield + * @return int + */ + protected function get_course_field_type(string $coursefield): int { + switch ($coursefield) { + case 'downloadcontent': + case 'enablecompletion': + case 'groupmodeforce': + case 'visible': + $fieldtype = column::TYPE_BOOLEAN; + break; + case 'startdate': + case 'enddate': + $fieldtype = column::TYPE_TIMESTAMP; + break; + case 'summary': + $fieldtype = column::TYPE_LONGTEXT; + break; + case 'category': + case 'groupmode': + $fieldtype = column::TYPE_INTEGER; + break; + case 'calendartype': + case 'idnumber': + case 'format': + case 'fullname': + case 'lang': + case 'shortname': + case 'theme': + default: + $fieldtype = column::TYPE_TEXT; + break; + } + + return $fieldtype; + } + + /** + * Returns list of all available columns. + * + * These are all the columns available to use in any report that uses this entity. + * + * @return column[] + */ + protected function get_all_columns(): array { + $columns = []; + $coursefields = $this->get_course_fields(); + $tablealias = $this->get_table_alias('course'); + $contexttablealias = $this->get_table_alias('context'); + + // Columns course full name with link, course short name with link and course id with link. + $fields = [ + 'coursefullnamewithlink' => 'fullname', + 'courseshortnamewithlink' => 'shortname', + 'courseidnumberewithlink' => 'idnumber', + ]; + foreach ($fields as $key => $field) { + $columns[] = (new column( + $key, + new lang_string($key, 'core_reportbuilder'), + $this->get_entity_name() + )) + ->add_joins($this->get_joins()) + ->set_type(column::TYPE_TEXT) + ->add_fields("{$tablealias}.{$field} as $key, {$tablealias}.id") + ->set_is_sortable(true) + ->add_callback(static function(?string $value, stdClass $row): string { + if ($value === null) { + return ''; + } + + return html_writer::link(course_get_url($row->id), $value); + }); + } + + foreach ($coursefields as $coursefield => $coursefieldlang) { + $column = (new column( + $coursefield, + $coursefieldlang, + $this->get_entity_name() + )) + ->add_joins($this->get_joins()) + ->set_type($this->get_course_field_type($coursefield)) + ->add_field("$tablealias.$coursefield") + ->add_callback([$this, 'format'], $coursefield) + ->set_is_sortable($this->is_sortable($coursefield)); + + // Join on the context table so that we can use it for formatting these columns later. + if ($coursefield === 'summary' || $coursefield === 'shortname' || $coursefield === 'fullname') { + $join = "LEFT JOIN {context} {$contexttablealias} + ON {$contexttablealias}.contextlevel = " . CONTEXT_COURSE . " + AND {$contexttablealias}.instanceid = {$tablealias}.id"; + + $column->add_join($join) + ->add_field("{$tablealias}.id", 'courseid') + ->add_fields(context_helper::get_preload_record_columns_sql($contexttablealias)); + } + + $columns[] = $column; + } + + return $columns; + } + + /** + * Returns list of all available filters + * + * @return array + */ + protected function get_all_filters(): array { + global $DB; + + $filters = []; + $tablealias = $this->get_table_alias('course'); + + $fields = $this->get_course_fields(); + foreach ($fields as $field => $name) { + // Filtering isn't supported for LONGTEXT fields on Oracle. + if ($this->get_course_field_type($field) === column::TYPE_LONGTEXT && + $DB->get_dbfamily() === 'oracle') { + + continue; + } + + $optionscallback = [static::class, 'get_options_for_' . $field]; + if (is_callable($optionscallback)) { + $filterclass = select::class; + } else if ($this->get_course_field_type($field) === column::TYPE_BOOLEAN) { + $filterclass = boolean_select::class; + } else if ($this->get_course_field_type($field) === column::TYPE_TIMESTAMP) { + $filterclass = date::class; + } else { + $filterclass = text::class; + } + + $filter = (new filter( + $filterclass, + $field, + $name, + $this->get_entity_name(), + "{$tablealias}.$field" + )) + ->add_joins($this->get_joins()); + + // Populate filter options by callback, if available. + if (is_callable($optionscallback)) { + $filter->set_options_callback($optionscallback); + } + + $filters[] = $filter; + } + + // We add our own custom course selector filter. + $filters[] = (new filter( + course_selector::class, + 'courseselector', + new lang_string('courses'), + $this->get_entity_name(), + "{$tablealias}.id" + )) + ->add_joins($this->get_joins()); + + return $filters; + } + + /** + * Gets list of options if the filter supports it + * + * @param string $fieldname + * @return null|array + */ + protected function get_options_for(string $fieldname): ?array { + static $cached = []; + if (!array_key_exists($fieldname, $cached)) { + $callable = [static::class, 'get_options_for_' . $fieldname]; + if (is_callable($callable)) { + $cached[$fieldname] = $callable(); + } else { + $cached[$fieldname] = null; + } + } + return $cached[$fieldname]; + } + + /** + * List of options for the field groupmode. + * + * @return array + */ + public static function get_options_for_groupmode(): array { + return [ + NOGROUPS => get_string('groupsnone', 'group'), + SEPARATEGROUPS => get_string('groupsseparate', 'group'), + VISIBLEGROUPS => get_string('groupsvisible', 'group'), + ]; + } + + /** + * List of options for the field category. + * + * @return array + */ + public static function get_options_for_category(): array { + return core_course_category::make_categories_list('moodle/category:viewcourselist'); + } + + /** + * List of options for the field format. + * + * @return array + */ + public static function get_options_for_format(): array { + global $CFG; + require_once($CFG->dirroot.'/course/lib.php'); + + $options = []; + + $courseformats = get_sorted_course_formats(true); + foreach ($courseformats as $courseformat) { + $options[$courseformat] = get_string('pluginname', "format_{$courseformat}"); + } + + return $options; + } + + /** + * List of options for the field theme. + * + * @return array + */ + public static function get_options_for_theme(): array { + $options = []; + + $themeobjects = get_list_of_themes(); + foreach ($themeobjects as $key => $theme) { + if (empty($theme->hidefromselector)) { + $options[$key] = get_string('pluginname', "theme_{$theme->name}"); + } + } + + return $options; + } + + /** + * List of options for the field lang. + * + * @return array + */ + public static function get_options_for_lang(): array { + return get_string_manager()->get_list_of_translations(); + } + + /** + * List of options for the field. + * + * @return array + */ + public static function get_options_for_calendartype(): array { + return \core_calendar\type_factory::get_list_of_calendar_types(); + } + + /** + * Formats the course field for display. + * + * @param mixed $value Current field value. + * @param stdClass $row Complete row. + * @param string $fieldname Name of the field to format. + * @return string + */ + public function format($value, stdClass $row, string $fieldname): string { + if ($this->get_course_field_type($fieldname) === column::TYPE_TIMESTAMP) { + return format::userdate($value, $row); + } + + $options = $this->get_options_for($fieldname); + if ($options !== null && array_key_exists($value, $options)) { + return $options[$value]; + } + + if ($this->get_course_field_type($fieldname) === column::TYPE_BOOLEAN) { + return format::boolean_as_text($value); + } + + if (in_array($fieldname, ['fullname', 'shortname'])) { + if (!$row->courseid) { + return ''; + } + context_helper::preload_from_record($row); + $context = context_course::instance($row->courseid); + return format_string($value, true, ['context' => $context->id, 'escape' => false]); + } + + if (in_array($fieldname, ['summary'])) { + if (!$row->courseid) { + return ''; + } + context_helper::preload_from_record($row); + $context = context_course::instance($row->courseid); + $summary = file_rewrite_pluginfile_urls($row->summary, 'pluginfile.php', $context->id, 'course', 'summary', null); + return format_text($summary); + } + + return s($value); + } +} diff --git a/reportbuilder/classes/local/entities/user.php b/reportbuilder/classes/local/entities/user.php new file mode 100644 index 00000000000..89c5b704864 --- /dev/null +++ b/reportbuilder/classes/local/entities/user.php @@ -0,0 +1,396 @@ +. + +declare(strict_types=1); + +namespace core_reportbuilder\local\entities; + +use context_system; +use html_writer; +use lang_string; +use moodle_url; +use stdClass; +use core_user\fields; +use core_reportbuilder\local\filters\boolean_select; +use core_reportbuilder\local\filters\date; +use core_reportbuilder\local\filters\select; +use core_reportbuilder\local\filters\text; +use core_reportbuilder\local\helpers\user_profile_fields; +use core_reportbuilder\local\helpers\format; +use core_reportbuilder\local\report\column; +use core_reportbuilder\local\report\filter; + +/** + * User entity class implementation. + * + * This entity defines all the user columns and filters to be used in any report. + * + * @package core_reportbuilder + * @copyright 2020 Sara Arjona based on Marina Glancy code. + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class user extends base { + + /** + * Database tables that this entity uses and their default aliases + * + * @return array + */ + protected function get_default_table_aliases(): array { + return ['user' => 'u']; + } + + /** + * The default title for this entity + * + * @return lang_string + */ + protected function get_default_entity_title(): lang_string { + return new lang_string('entityuser', 'core_reportbuilder'); + } + + /** + * Initialise the entity, add all user fields and all 'visible' user profile fields + * + * @return base + */ + public function initialise(): base { + $userprofilefields = $this->get_user_profile_fields(); + + $columns = array_merge($this->get_all_columns(), $userprofilefields->get_columns()); + foreach ($columns as $column) { + $this->add_column($column); + } + + $filters = array_merge($this->get_all_filters(), $userprofilefields->get_filters()); + foreach ($filters as $filter) { + $this->add_filter($filter); + } + + return $this; + } + + /** + * Get user profile fields helper instance + * + * @return user_profile_fields + */ + protected function get_user_profile_fields(): user_profile_fields { + $userprofilefields = new user_profile_fields($this->get_table_alias('user') . '.id', $this->get_entity_name()); + $userprofilefields->add_joins($this->get_joins()); + return $userprofilefields; + } + + /** + * Returns list of all available columns + * + * These are all the columns available to use in any report that uses this entity. + * + * @return column[] + */ + protected function get_all_columns(): array { + $usertablealias = $this->get_table_alias('user'); + + $fullnameselect = self::get_name_fields_select($usertablealias); + $userpictureselect = fields::for_userpic()->get_sql($usertablealias, false, '', '', false)->selects; + $viewfullnames = has_capability('moodle/site:viewfullnames', context_system::instance()); + + // Fullname column. + $columns[] = (new column( + 'fullname', + new lang_string('fullname'), + $this->get_entity_name() + )) + ->add_joins($this->get_joins()) + ->add_fields($fullnameselect) + ->set_type(column::TYPE_TEXT) + ->set_is_sortable($this->is_sortable('fullname')) + ->add_callback(static function(?string $value, stdClass $row) use ($viewfullnames): string { + if ($value === null) { + return ''; + } + + return fullname($row, $viewfullnames); + }); + + // Formatted fullname columns (with link, picture or both). + $fullnamefields = [ + 'fullnamewithlink' => new lang_string('userfullnamewithlink', 'core_reportbuilder'), + 'fullnamewithpicture' => new lang_string('userfullnamewithpicture', 'core_reportbuilder'), + 'fullnamewithpicturelink' => new lang_string('userfullnamewithpicturelink', 'core_reportbuilder'), + ]; + foreach ($fullnamefields as $fullnamefield => $fullnamelang) { + $column = (new column( + $fullnamefield, + $fullnamelang, + $this->get_entity_name() + )) + ->add_joins($this->get_joins()) + ->add_fields($fullnameselect) + ->add_field("{$usertablealias}.id") + ->set_type(column::TYPE_TEXT) + ->set_is_sortable($this->is_sortable($fullnamefield)) + ->add_callback(static function(?string $value, stdClass $row) use ($fullnamefield, $viewfullnames): string { + global $OUTPUT; + + if ($value === null) { + return ''; + } + + if ($fullnamefield === 'fullnamewithlink') { + return html_writer::link(new moodle_url('/user/profile.php', ['id' => $row->id]), + fullname($row, $viewfullnames)); + } + if ($fullnamefield === 'fullnamewithpicture') { + return $OUTPUT->user_picture($row, ['link' => false, 'alttext' => false]) . + fullname($row, $viewfullnames); + } + if ($fullnamefield === 'fullnamewithpicturelink') { + return html_writer::link(new moodle_url('/user/profile.php', ['id' => $row->id]), + $OUTPUT->user_picture($row, ['link' => false, 'alttext' => false]) . + fullname($row, $viewfullnames)); + } + + return $value; + }); + + // Picture fields need some more data. + if (strpos($fullnamefield, 'picture') !== false) { + $column->add_fields($userpictureselect); + } + + $columns[] = $column; + } + + // Picture column. + $columns[] = (new column( + 'picture', + new lang_string('userpicture', 'core_reportbuilder'), + $this->get_entity_name() + )) + ->add_joins($this->get_joins()) + ->add_fields($userpictureselect) + ->set_type(column::TYPE_INTEGER) + ->set_is_sortable($this->is_sortable('picture')) + ->add_callback(static function (int $value, stdClass $row): string { + global $OUTPUT; + + return !empty($row->id) ? $OUTPUT->user_picture($row, ['link' => false, 'alttext' => false]) : ''; + }); + + // Add all other user fields. + $userfields = $this->get_user_fields(); + foreach ($userfields as $userfield => $userfieldlang) { + $columntype = $this->get_user_field_type($userfield); + + $column = (new column( + $userfield, + $userfieldlang, + $this->get_entity_name() + )) + ->add_joins($this->get_joins()) + ->add_field("{$usertablealias}.{$userfield}") + ->set_type($columntype) + ->set_is_sortable($this->is_sortable($userfield)) + ->add_callback([$this, 'format'], $userfield); + + // Some columns also have specific format callbacks. + if ($userfield === 'country') { + $column->add_callback(static function(string $country): string { + $countries = get_string_manager()->get_list_of_countries(true); + return $countries[$country] ?? ''; + }); + } + + $columns[] = $column; + } + + return $columns; + } + + /** + * Check if this field is sortable + * + * @param string $fieldname + * @return bool + */ + protected function is_sortable(string $fieldname): bool { + // Some columns can't be sorted, like longtext or images. + $nonsortable = [ + 'picture', + ]; + + return !in_array($fieldname, $nonsortable); + } + + /** + * Formats the user field for display. + * + * @param mixed $value Current field value. + * @param stdClass $row Complete row. + * @param string $fieldname Name of the field to format. + * @return string + */ + public function format($value, stdClass $row, string $fieldname): string { + if ($this->get_user_field_type($fieldname) === column::TYPE_BOOLEAN) { + return format::boolean_as_text($value); + } + + if ($this->get_user_field_type($fieldname) === column::TYPE_TIMESTAMP) { + return format::userdate($value, $row); + } + + return s($value); + } + + /** + * Returns a SQL statement to select all user fields necessary for fullname() function + * + * @param string $usertablealias + * @return string + */ + public static function get_name_fields_select(string $usertablealias = 'u'): string { + $userfields = array_map(static function(string $userfield) use ($usertablealias): string { + if (!empty($usertablealias)) { + $userfield = "{$usertablealias}.{$userfield}"; + } + + return $userfield; + }, fields::get_name_fields(true)); + + return implode(', ', $userfields); + } + + /** + * User fields + * + * @return lang_string[] + */ + protected function get_user_fields(): array { + return [ + 'firstname' => new lang_string('firstname'), + 'lastname' => new lang_string('lastname'), + 'email' => new lang_string('email'), + 'city' => new lang_string('city'), + 'country' => new lang_string('country'), + 'firstnamephonetic' => new lang_string('firstnamephonetic'), + 'lastnamephonetic' => new lang_string('lastnamephonetic'), + 'middlename' => new lang_string('middlename'), + 'alternatename' => new lang_string('alternatename'), + 'idnumber' => new lang_string('idnumber'), + 'institution' => new lang_string('institution'), + 'department' => new lang_string('department'), + 'phone1' => new lang_string('phone1'), + 'phone2' => new lang_string('phone2'), + 'address' => new lang_string('address'), + 'lastaccess' => new lang_string('lastaccess'), + 'suspended' => new lang_string('suspended'), + 'confirmed' => new lang_string('confirmed', 'admin'), + 'username' => new lang_string('username'), + 'moodlenetprofile' => new lang_string('moodlenetprofile', 'user'), + ]; + } + + /** + * Return appropriate column type for given user field + * + * @param string $userfield + * @return int + */ + protected function get_user_field_type(string $userfield): int { + switch ($userfield) { + case 'confirmed': + case 'suspended': + $fieldtype = column::TYPE_BOOLEAN; + break; + case 'lastaccess': + $fieldtype = column::TYPE_TIMESTAMP; + break; + default: + $fieldtype = column::TYPE_TEXT; + break; + } + + return $fieldtype; + } + + /** + * Return list of all available filters + * + * @return filter[] + */ + protected function get_all_filters(): array { + global $DB; + + $filters = []; + $tablealias = $this->get_table_alias('user'); + + // Fullname filter. + $canviewfullnames = has_capability('moodle/site:viewfullnames', context_system::instance()); + [$fullnamesql, $fullnameparams] = fields::get_sql_fullname($tablealias, $canviewfullnames); + $filters[] = (new filter( + text::class, + 'fullname', + new lang_string('fullname'), + $this->get_entity_name(), + $fullnamesql, + $fullnameparams + )) + ->add_joins($this->get_joins()); + + // User fields filters. + $fields = $this->get_user_fields(); + foreach ($fields as $field => $name) { + $optionscallback = [static::class, 'get_options_for_' . $field]; + if (is_callable($optionscallback)) { + $classname = select::class; + } else if ($this->get_user_field_type($field) === column::TYPE_BOOLEAN) { + $classname = boolean_select::class; + } else if ($this->get_user_field_type($field) === column::TYPE_TIMESTAMP) { + $classname = date::class; + } else { + $classname = text::class; + } + + $filter = (new filter( + $classname, + $field, + $name, + $this->get_entity_name(), + $tablealias . '.' . $field + )) + ->add_joins($this->get_joins()); + + // Populate filter options by callback, if available. + if (is_callable($optionscallback)) { + $filter->set_options_callback($optionscallback); + } + + $filters[] = $filter; + } + + return $filters; + } + + /** + * List of options for the field country. + * + * @return string[] + */ + public static function get_options_for_country(): array { + return array_map('shorten_text', get_string_manager()->get_list_of_countries()); + } +} diff --git a/reportbuilder/classes/local/helpers/custom_fields.php b/reportbuilder/classes/local/helpers/custom_fields.php new file mode 100644 index 00000000000..8f39aa23dd1 --- /dev/null +++ b/reportbuilder/classes/local/helpers/custom_fields.php @@ -0,0 +1,296 @@ +. + +declare(strict_types=1); + +namespace core_reportbuilder\local\helpers; + +use core_reportbuilder\local\filters\boolean_select; +use core_reportbuilder\local\filters\date; +use core_reportbuilder\local\filters\number; +use core_reportbuilder\local\filters\select; +use core_reportbuilder\local\filters\text; +use core_reportbuilder\local\report\column; +use core_reportbuilder\local\report\filter; +use lang_string; +use stdClass; +use core_customfield\data; +use core_customfield\data_controller; +use core_customfield\field_controller; +use core_customfield\handler; + +/** + * Helper class for course custom fields. + * + * @package core_reportbuilder + * @copyright 2021 Sara Arjona based on David Matamoros code. + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class custom_fields { + + /** @var string $entityname Name of the entity */ + private $entityname; + + /** @var handler $handler The handler for the customfields */ + private $handler; + + /** @var int $tablefieldalias The table alias and the field name (table.field) that matches the customfield instanceid. */ + private $tablefieldalias; + + /** @var array additional joins */ + private $joins = []; + + /** + * Class customfields constructor. + * + * @param string $tablefieldalias table alias and the field name (table.field) that matches the customfield instanceid. + * @param string $entityname name of the entity in the report where we add custom fields. + * @param string $component component name of full frankenstyle plugin name. + * @param string $area name of the area (each component/plugin may define handlers for multiple areas). + * @param int $itemid item id if the area uses them (usually not used). + */ + public function __construct(string $tablefieldalias, string $entityname, string $component, string $area, int $itemid = 0) { + $this->tablefieldalias = $tablefieldalias; + $this->entityname = $entityname; + $this->handler = handler::get_handler($component, $area, $itemid); + } + + /** + * Additional join that is needed. + * + * @param string $join + * @return self + */ + public function add_join(string $join): self { + $this->joins[trim($join)] = trim($join); + return $this; + } + + /** + * Additional joins that are needed. + * + * @param array $joins + * @return self + */ + public function add_joins(array $joins): self { + foreach ($joins as $join) { + $this->add_join($join); + } + return $this; + } + + /** + * Return joins + * + * @return string[] + */ + private function get_joins(): array { + return array_values($this->joins); + } + + /** + * Gets the custom fields columns for the report. + * + * Column will be named as 'customfield_' + customfield shortname. + * + * @return column[] + */ + public function get_columns(): array { + $columns = []; + + $categorieswithfields = $this->handler->get_categories_with_fields(); + foreach ($categorieswithfields as $fieldcategory) { + $categoryfields = $fieldcategory->get_fields(); + foreach ($categoryfields as $field) { + $customdatatablealias = database::generate_alias(); + + $datacontroller = data_controller::create(0, null, $field); + $datafield = $datacontroller->datafield(); + + $alias = 'customfield_' . $field->get('shortname'); + $selectfields = "{$customdatatablealias}.{$datafield} as {$alias}, {$customdatatablealias}.id"; + if ($datafield === 'value') { + // We will take the format into account when displaying the individual values. + $selectfields .= ", {$customdatatablealias}.valueformat"; + } + + $columnname = $field->get_formatted_name(); + $columntype = $this->get_column_type($field, $datafield); + + $newcolumn = (new column( + 'customfield_' . $field->get('shortname'), + new lang_string('customfieldcolumn', 'core_reportbuilder', $columnname), + $this->entityname + )) + ->add_joins($this->get_joins()) + ->add_join("LEFT JOIN {customfield_data} {$customdatatablealias} " . + "ON {$customdatatablealias}.fieldid = " . $field->get('id') . " " . + "AND {$customdatatablealias}.instanceid = {$this->tablefieldalias}") + ->add_fields($selectfields) + ->set_type($columntype) + ->set_is_sortable($columntype !== column::TYPE_LONGTEXT) + ->add_callback([$this, 'customfield_value'], $field) + // Important. If the handler implements can_view() function, it will be called with parameter $instanceid=0. + // This means that per-instance access validation will be ignored. + ->set_is_available($this->handler->can_view($field, 0)); + + $columns[] = $newcolumn; + } + } + return $columns; + } + + /** + * Returns the column type + * + * @param field_controller $field + * @param string $datafield + * @return int + */ + private function get_column_type(field_controller $field, string $datafield): int { + if ($field->get('type') === 'checkbox') { + return column::TYPE_BOOLEAN; + } + + if ($field->get('type') === 'date') { + return column::TYPE_TIMESTAMP; + } + + if ($datafield === 'intvalue') { + return column::TYPE_INTEGER; + } + + if ($datafield === 'decvalue') { + return column::TYPE_FLOAT; + } + + if ($datafield === 'value') { + return column::TYPE_LONGTEXT; + } + + return column::TYPE_TEXT; + } + + /** + * Returns all available filters on custom fields. + * + * Filter will be named as 'customfield_' + customfield shortname. + * + * @return filter[] + */ + public function get_filters(): array { + $filters = []; + + $categorieswithfields = $this->handler->get_categories_with_fields(); + foreach ($categorieswithfields as $fieldcategory) { + $categoryfields = $fieldcategory->get_fields(); + foreach ($categoryfields as $field) { + $customdatatablealias = database::generate_alias(); + + $datacontroller = data_controller::create(0, null, $field); + $datafield = $datacontroller->datafield(); + $typeclass = $this->get_filter_class_type($datacontroller); + + $filter = (new filter( + $typeclass, + 'customfield_' . $field->get('shortname'), + new lang_string('customfieldcolumn', 'core_reportbuilder', $field->get('name')), + $this->entityname, + "{$customdatatablealias}.{$datafield}" + )) + ->add_joins($this->get_joins()) + ->add_join("LEFT JOIN {customfield_data} {$customdatatablealias} " . + "ON {$customdatatablealias}.fieldid = " . $field->get('id') . " " . + "AND {$customdatatablealias}.instanceid = {$this->tablefieldalias}"); + + // Options are stored inside configdata json string and we need to convert it to array. + if ($field->get('type') === 'select') { + $filter->set_options_callback(static function() use ($field): array { + $options = explode("\r\n", $field->get_configdata_property('options')); + // Method set_options starts using array at index 1. we shift one position on this array. + // In course settings this menu has an empty option and we need to respect that. + array_unshift($options, " "); + unset($options[0]); + return $options; + }); + } + + $filters[] = $filter; + } + } + return $filters; + } + + /** + * Returns class for the filter element that should be used for the field + * + * In some situation we can assume what kind of data is stored in the customfield plugin and we can + * display appropriate filter form element. For all others assume text filter. + * + * @param data_controller $datacontroller + * @return string + */ + private function get_filter_class_type(data_controller $datacontroller): string { + $type = $datacontroller->get_field()->get('type'); + + switch ($type) { + case 'checkbox': + $classtype = boolean_select::class; + break; + case 'date': + $classtype = date::class; + break; + case 'select': + $classtype = select::class; + break; + default: + // To support third party field type we need to account for stored numbers. + $datafield = $datacontroller->datafield(); + if ($datafield === 'intvalue' || $datafield === 'decvalue') { + $classtype = number::class; + } else { + $classtype = text::class; + } + break; + } + + return $classtype; + } + + /** + * Format for custom fields value. We get the correct custom field value using export_value method. + * + * @param mixed $value Current value. + * @param stdClass $row Full row. + * @param field_controller $field Field controller object. + * @return mixed|null + */ + public function customfield_value($value, stdClass $row, field_controller $field) { + $defaults = [ + 'id' => -1, + 'shortcharvalue' => $value, + 'charvalue' => $value, + 'intvalue' => (int)$value, + 'decvalue' => (float)$value, + 'value' => $value, + 'fieldid' => $field->get('id'), + 'valueformat' => FORMAT_HTML, + ]; + $row = array_intersect_key(array_merge($defaults, (array)$row), data::properties_definition()); + $data = data_controller::create(0, (object)$row, $field); + return $data->export_value(); + } +} diff --git a/reportbuilder/classes/local/helpers/user_profile_fields.php b/reportbuilder/classes/local/helpers/user_profile_fields.php new file mode 100644 index 00000000000..118354cc6d1 --- /dev/null +++ b/reportbuilder/classes/local/helpers/user_profile_fields.php @@ -0,0 +1,242 @@ +. + +declare(strict_types=1); + +namespace core_reportbuilder\local\helpers; + +use context_system; +use core_reportbuilder\local\filters\boolean_select; +use core_reportbuilder\local\filters\date; +use core_reportbuilder\local\filters\select; +use core_reportbuilder\local\filters\text; +use core_reportbuilder\local\report\column; +use core_reportbuilder\local\report\filter; +use lang_string; +use profile_field_base; +use stdClass; + +defined('MOODLE_INTERNAL') || die(); + +global $CFG; +require_once($CFG->dirroot.'/user/profile/lib.php'); + +/** + * Helper class for user profile fields. + * + * @package core_reportbuilder + * @copyright 2021 David Matamoros + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class user_profile_fields { + + /** @var array user profile fields */ + private $userprofilefields; + + /** @var string $entityname Name of the entity */ + private $entityname; + + /** @var int $usertablefieldalias The user table/field alias */ + private $usertablefieldalias; + + /** @var array additional joins */ + private $joins = []; + + /** + * Class userprofilefields constructor. + * + * @param string $usertablefieldalias The user table/field alias used when adding columns and filters. + * @param string $entityname The entity name used when adding columns and filters. + */ + public function __construct(string $usertablefieldalias, string $entityname) { + $this->usertablefieldalias = $usertablefieldalias; + $this->entityname = $entityname; + $this->userprofilefields = $this->get_user_profile_fields(); + } + + /** + * Retrieves the list of available/visible user profile fields + * + * @return profile_field_base[] + */ + private function get_user_profile_fields(): array { + return array_filter(profile_get_user_fields_with_data(0), static function($profilefield): bool { + return (int)$profilefield->field->visible === (int)PROFILE_VISIBLE_ALL; + }); + } + + /** + * Additional join that is needed. + * + * @param string $join + * @return self + */ + public function add_join(string $join): self { + $this->joins[trim($join)] = trim($join); + return $this; + } + + /** + * Additional joins that are needed. + * + * @param array $joins + * @return self + */ + public function add_joins(array $joins): self { + foreach ($joins as $join) { + $this->add_join($join); + } + return $this; + } + + /** + * Return joins + * + * @return string[] + */ + private function get_joins(): array { + return array_values($this->joins); + } + + /** + * Return the user profile fields visible columns. + * + * @return column[] + */ + public function get_columns(): array { + $columns = []; + + foreach ($this->userprofilefields as $profilefield) { + $userinfotablealias = database::generate_alias(); + + $column = (new column( + 'profilefield_' . $profilefield->field->shortname, + new lang_string('customfieldcolumn', 'core_reportbuilder', + format_string($profilefield->field->name, true, + ['escape' => true, 'context' => context_system::instance()])), + $this->entityname + )) + ->add_joins($this->get_joins()) + ->add_join("LEFT JOIN {user_info_data} {$userinfotablealias} " . + "ON {$userinfotablealias}.userid = {$this->usertablefieldalias} " . + "AND {$userinfotablealias}.fieldid = {$profilefield->fieldid}") + ->add_field("{$userinfotablealias}.data", 'profilefield_' . $profilefield->field->shortname) + ->set_type($this->get_user_field_type($profilefield->field->datatype)) + ->add_callback([$this, 'format_profile_field'], $profilefield); + + $columns[] = $column; + } + + return $columns; + } + + /** + * Get custom user profile fields filters. + * + * @return filter[] + */ + public function get_filters(): array { + global $DB; + + $filters = []; + foreach ($this->userprofilefields as $profilefield) { + $userinfotablealias = database::generate_alias(); + $field = "{$userinfotablealias}.data"; + + switch ($profilefield->field->datatype) { + case 'checkbox': + $classname = boolean_select::class; + $field = $DB->sql_cast_char2int($field); + break; + case 'datetime': + $classname = date::class; + $field = $DB->sql_cast_char2int($field); + break; + case 'menu': + $classname = select::class; + break; + case 'text': + case 'textarea': + default: + $field = $DB->sql_compare_text($field, 255); + $classname = text::class; + break; + } + + $filter = (new filter( + $classname, + 'profilefield_' . $profilefield->field->shortname, + new lang_string('customfieldcolumn', 'core_reportbuilder', + format_string($profilefield->field->name, true, + ['escape' => false, 'context' => context_system::instance()])), + $this->entityname, + $field + )) + ->add_joins($this->get_joins()) + ->add_join("LEFT JOIN {user_info_data} {$userinfotablealias} " . + "ON {$userinfotablealias}.userid = {$this->usertablefieldalias} " . + "AND {$userinfotablealias}.fieldid = {$profilefield->fieldid}"); + + // If menu type then set filter options as appropriate. + if ($profilefield->field->datatype === 'menu') { + $filter->set_options($profilefield->options); + } + + $filters[] = $filter; + } + + return $filters; + } + + /** + * Get user profile field type for report. + * + * @param string $userfield user field. + * @return int the constant equivalent to this custom field type. + */ + protected function get_user_field_type(string $userfield): int { + switch ($userfield) { + case 'checkbox': + $customfieldtype = column::TYPE_BOOLEAN; + break; + case 'datetime': + $customfieldtype = column::TYPE_TIMESTAMP; + break; + case 'textarea': + $customfieldtype = column::TYPE_LONGTEXT; + break; + case 'menu': + case 'text': + default: + $customfieldtype = column::TYPE_TEXT; + break; + } + return $customfieldtype; + } + + /** + * Formatter for a profile field. It formats the field according to its type. + * + * @param mixed $value + * @param stdClass $row + * @param profile_field_base $field + * @return string + */ + public static function format_profile_field($value, stdClass $row, profile_field_base $field): string { + $field->data = $value; + return $field->display_data(); + } +} diff --git a/reportbuilder/classes/local/report/base.php b/reportbuilder/classes/local/report/base.php index cef89c61efb..cbdac87a6a0 100644 --- a/reportbuilder/classes/local/report/base.php +++ b/reportbuilder/classes/local/report/base.php @@ -21,6 +21,7 @@ namespace core_reportbuilder\local\report; use coding_exception; use context; use lang_string; +use core_reportbuilder\local\entities\base as entity_base; use core_reportbuilder\local\filters\base as filter_base; use core_reportbuilder\local\helpers\database; use core_reportbuilder\local\helpers\user_filter_manager; @@ -62,6 +63,9 @@ abstract class base { /** @var array $sqlparams */ private $sqlparams = []; + /** @var entity_base[] $entities */ + private $entities = []; + /** @var lang_string[] */ private $entitytitles = []; @@ -243,6 +247,17 @@ abstract class base { ]; } + /** + * Adds given entity, along with it's columns and filters, to the report + * + * @param entity_base $entity + */ + final protected function add_entity(entity_base $entity): void { + $entityname = $entity->get_entity_name(); + $this->annotate_entity($entityname, $entity->get_entity_title()); + $this->entities[$entityname] = $entity->initialise(); + } + /** * Define a new entity for the report * @@ -285,6 +300,26 @@ abstract class base { return $column; } + /** + * Add given columns to the report from one or more entities + * + * Each entity must have already been added to the report before calling this method + * + * @param string[] $columns Unique identifier of each entity column + * @throws coding_exception For unknown entities + */ + final protected function add_columns_from_entities(array $columns): void { + foreach ($columns as $column) { + [$entityname, $columnname] = explode(':', $column, 2); + + if (!array_key_exists($entityname, $this->entities)) { + throw new coding_exception('Invalid entity name', $entityname); + } + + $this->add_column($this->entities[$entityname]->get_column($columnname)); + } + } + /** * Return report column by unique identifier * @@ -333,6 +368,26 @@ abstract class base { return $filter; } + /** + * Add given filters to the report from one or more entities + * + * Each entity must have already been added to the report before calling this method + * + * @param string[] $filters Unique identifier of each entity filter + * @throws coding_exception For unknown entities + */ + final protected function add_filters_from_entities(array $filters): void { + foreach ($filters as $filter) { + [$entityname, $filtername] = explode(':', $filter, 2); + + if (!array_key_exists($entityname, $this->entities)) { + throw new coding_exception('Invalid entity name', $entityname); + } + + $this->add_filter($this->entities[$entityname]->get_filter($filtername)); + } + } + /** * Return report filter by unique identifier * diff --git a/reportbuilder/tests/fixtures/course_entity_report.php b/reportbuilder/tests/fixtures/course_entity_report.php new file mode 100644 index 00000000000..239c0554b10 --- /dev/null +++ b/reportbuilder/tests/fixtures/course_entity_report.php @@ -0,0 +1,76 @@ +. + +declare(strict_types=1); + +namespace core_reportbuilder; + +use core_reportbuilder\local\entities\course; +use core_reportbuilder\local\helpers\database; + +/** + * Testable system report fixture for testing the course entity + * + * @package core_reportbuilder + * @copyright 2021 David Matamoros + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class course_entity_report extends system_report { + + /** + * Initialise the report + */ + protected function initialise(): void { + $entity = new course(); + $coursetablealias = $entity->get_table_alias('course'); + $param = database::generate_param_name(); + + $this->set_main_table('course', $coursetablealias); + $this->add_entity($entity); + // Add a base condition to hide the site course. + $this->add_base_condition_sql("$coursetablealias.id <> :$param", [$param => SITEID]); + + $columns = []; + foreach ($entity->get_columns() as $column) { + $columns[] = $column->get_unique_identifier(); + } + $this->add_columns_from_entities($columns); + + $filters = []; + foreach ($entity->get_filters() as $filter) { + $filters[] = $filter->get_unique_identifier(); + } + $this->add_filters_from_entities($filters); + } + + /** + * Ensure we can view the report + * + * @return bool + */ + protected function can_view(): bool { + return true; + } + + /** + * Explicitly set availability of report + * + * @return bool + */ + public static function is_available(): bool { + return true; + } +} diff --git a/reportbuilder/tests/fixtures/user_entity_report.php b/reportbuilder/tests/fixtures/user_entity_report.php new file mode 100644 index 00000000000..721de588a2f --- /dev/null +++ b/reportbuilder/tests/fixtures/user_entity_report.php @@ -0,0 +1,71 @@ +. + +declare(strict_types=1); + +namespace core_reportbuilder; + +use core_reportbuilder\local\entities\user; + +/** + * Testable system report fixture for testing the user entity + * + * @package core_reportbuilder + * @copyright 2021 Paul Holden + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class user_entity_report extends system_report { + + /** + * Initialise the report + */ + protected function initialise(): void { + $entity = new user(); + + $this->set_main_table('user', $entity->get_table_alias('user')); + $this->add_entity($entity); + + $columns = []; + foreach ($entity->get_columns() as $column) { + $columns[] = $column->get_unique_identifier(); + } + $this->add_columns_from_entities($columns); + + $filters = []; + foreach ($entity->get_filters() as $filter) { + $filters[] = $filter->get_unique_identifier(); + } + $this->add_filters_from_entities($filters); + } + + /** + * Ensure we can view the report + * + * @return bool + */ + protected function can_view(): bool { + return true; + } + + /** + * Explicitly set availability of report + * + * @return bool + */ + public static function is_available(): bool { + return true; + } +} diff --git a/reportbuilder/tests/local/entities/course_test.php b/reportbuilder/tests/local/entities/course_test.php new file mode 100644 index 00000000000..884e4d8d05b --- /dev/null +++ b/reportbuilder/tests/local/entities/course_test.php @@ -0,0 +1,475 @@ +. + +declare(strict_types=1); + +namespace core_reportbuilder\local\entities; + +use advanced_testcase; +use coding_exception; +use context_system; +use lang_string; +use ReflectionClass; +use core_reportbuilder\course_entity_report; +use core_reportbuilder\manager; +use core_reportbuilder\testable_system_report_table; +use core_reportbuilder\local\filters\boolean_select; +use core_reportbuilder\local\filters\date; +use core_reportbuilder\local\filters\text; +use core_reportbuilder\local\filters\select; +use core_reportbuilder\local\helpers\user_filter_manager; + +/** + * Unit tests for course entity + * + * @package core_reportbuilder + * @covers \core_reportbuilder\local\entities\base + * @covers \core_reportbuilder\local\entities\course + * @covers \core_reportbuilder\local\helpers\custom_fields + * @covers \core_reportbuilder\local\report\base + * @covers \core_reportbuilder\system_report + * @copyright 2021 David Matamoros + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class course_testcase extends advanced_testcase { + + /** + * Load required classes + */ + public static function setUpBeforeClass(): void { + global $CFG; + + require_once("{$CFG->dirroot}/course/lib.php"); + require_once("{$CFG->dirroot}/reportbuilder/tests/fixtures/testable_system_report_table.php"); + require_once("{$CFG->dirroot}/reportbuilder/tests/fixtures/course_entity_report.php"); + } + + /** + * Generate courses for the tests + */ + public function generate_courses(): array { + $coursecategory1 = $this->getDataGenerator()->create_category(); + $course1 = $this->getDataGenerator()->create_course([ + 'fullname' => 'Course 1', + 'shortname' => 'C1', + 'idnumber' => 'IDNumber1', + 'visible' => 1, + 'startdate' => 289308600, + 'enddate' => 3445023600, + 'category' => $coursecategory1->id, + 'groupmode' => NOGROUPS, + 'enablecompletion' => 1, + 'downloadcontent' => DOWNLOAD_COURSE_CONTENT_DISABLED, + 'format' => 'topics', + 'calendartype' => 'Gregorian', + 'theme' => 'afterburner', + 'lang' => 'en', + ]); + + $coursecategory2 = $this->getDataGenerator()->create_category(); + $course2 = $this->getDataGenerator()->create_course([ + 'fullname' => 'Course 2', + 'shortname' => 'C2', + 'idnumber' => 'IDNumber2', + 'visible' => 0, + 'startdate' => 1614726000, + 'enddate' => 1961881200, + 'category' => $coursecategory2->id, + 'groupmode' => SEPARATEGROUPS, + 'enablecompletion' => 0, + 'downloadcontent' => DOWNLOAD_COURSE_CONTENT_ENABLED, + 'format' => 'topics', + 'calendartype' => 'Gregorian', + 'theme' => 'afterburner', + 'lang' => 'es', + ]); + + return [$coursecategory1, $course1, $coursecategory2, $course2]; + } + + /** + * Test callbacks are correctly applied for those columns using them + */ + public function test_get_columns_with_callbacks(): void { + $this->resetAfterTest(); + + [$coursecategory1, $course1] = $this->generate_courses(); + $testdate = time(); + + // Add some customfields to the course. + $cfgenerator = self::getDataGenerator()->get_plugin_generator('core_customfield'); + $params = [ + 'component' => 'core_course', + 'area' => 'course', + 'itemid' => 0, + 'contextid' => context_system::instance()->id + ]; + $category = $cfgenerator->create_category($params); + $field1 = $cfgenerator->create_field( + ['categoryid' => $category->get('id'), 'type' => 'text', 'name' => 'Customfield text 1', 'shortname' => 'cf1']); + $cfgenerator->add_instance_data($field1, (int)$course1->id, 'Do. Or do not. There is no try'); + $field2 = $cfgenerator->create_field( + ['categoryid' => $category->get('id'), 'type' => 'text', 'name' => 'Customfield text 2', 'shortname' => 'cf2']); + $cfgenerator->add_instance_data($field2, (int)$course1->id, 'Chewie, we are home'); + $field3 = $cfgenerator->create_field( + ['categoryid' => $category->get('id'), 'type' => 'checkbox', 'name' => 'Customfield checkbox', 'shortname' => 'cf3']); + $cfgenerator->add_instance_data($field3, (int)$course1->id, 1); + $field4 = $cfgenerator->create_field( + ['categoryid' => $category->get('id'), 'type' => 'date', 'name' => 'Customfield date', 'shortname' => 'cf4']); + $cfgenerator->add_instance_data($field4, (int)$course1->id, $testdate); + $field5 = $cfgenerator->create_field( + ['categoryid' => $category->get('id'), 'type' => 'select', 'name' => 'Customfield menu', 'shortname' => 'cf5', + 'configdata' => ['defaultvalue' => 'Option A', 'options' => "Option A\nOption B\nOption C"]]); + // Select option C for course1 (options are counted starting from one). + $cfgenerator->add_instance_data($field5, (int)$course1->id, 3); + + $tablerows = $this->get_report_table_rows(); + $courserows = array_filter($tablerows, static function(array $row) use ($course1): bool { + return $row['shortname'] === $course1->shortname; + }); + $courserow = reset($courserows); + + $this->assertEquals($coursecategory1->name, $courserow['category']); + $this->assertEquals('Course 1', $courserow['fullname']); + $this->assertEquals('C1', $courserow['shortname']); + $this->assertEquals('IDNumber1', $courserow['idnumber']); + $this->assertEquals('Yes', $courserow['visible']); + $this->assertEquals(userdate(289308600), $courserow['startdate']); + $this->assertEquals(userdate(3445023600), $courserow['enddate']); + $this->assertEquals('No groups', $courserow['groupmode']); + $this->assertEquals('Yes', $courserow['enablecompletion']); + $this->assertEquals('No', $courserow['downloadcontent']); + $this->assertEquals('Topics format', $courserow['format']); + $this->assertEquals('Gregorian', $courserow['calendartype']); + $this->assertEquals('afterburner', $courserow['theme']); + $this->assertEquals(get_string_manager()->get_list_of_translations()['en'], $courserow['lang']); + $expected = 'Course 1'; + $this->assertEquals($expected, $courserow['coursefullnamewithlink']); + $expected = 'C1'; + $this->assertEquals($expected, $courserow['courseshortnamewithlink']); + $expected = 'IDNumber1'; + $this->assertEquals($expected, $courserow['courseidnumberewithlink']); + $this->assertEquals('Do. Or do not. There is no try', $courserow['customfield_cf1']); + $this->assertEquals('Chewie, we are home', $courserow['customfield_cf2']); + $this->assertEquals('Yes', $courserow['customfield_cf3']); + $this->assertEquals(userdate($testdate), $courserow['customfield_cf4']); + $this->assertEquals('Option C', $courserow['customfield_cf5']); + } + + /** + * Test filtering report by course fields + */ + public function test_filters(): void { + $this->resetAfterTest(); + + [$coursecategory1] = $this->generate_courses(); + + // Filter by fullname field. + $filtervalues = [ + 'course:fullname_operator' => text::IS_EQUAL_TO, + 'course:fullname_value' => 'Course 1', + ]; + $tablerows = $this->get_report_table_rows($filtervalues); + $this->assertEquals([ + 'Course 1', + ], array_column($tablerows, 'fullname')); + + // Filter by shortname field. + $filtervalues = [ + 'course:shortname_operator' => text::IS_EQUAL_TO, + 'course:shortname_value' => 'C1', + ]; + $tablerows = $this->get_report_table_rows($filtervalues); + $this->assertEquals([ + 'Course 1', + ], array_column($tablerows, 'fullname')); + + // Filter by idnumber field. + $filtervalues = [ + 'course:idnumber_operator' => text::IS_EQUAL_TO, + 'course:idnumber_value' => 'IDNumber2', + ]; + $tablerows = $this->get_report_table_rows($filtervalues); + $this->assertEquals([ + 'Course 2', + ], array_column($tablerows, 'fullname')); + + // Filter by visible field. + $filtervalues = [ + 'course:visible_operator' => boolean_select::NOT_CHECKED, + ]; + $tablerows = $this->get_report_table_rows($filtervalues); + $this->assertEquals([ + 'Course 2', + ], array_column($tablerows, 'fullname')); + + // Filter by startdate field. + $filtervalues = [ + 'course:startdate_operator' => date::DATE_RANGE, + 'course:startdate_from' => 289135800, + 'course:startdate_to' => 289740600, + ]; + $tablerows = $this->get_report_table_rows($filtervalues); + $this->assertEquals([ + 'Course 1', + ], array_column($tablerows, 'fullname')); + + // Filter by category field. + $filtervalues = [ + 'course:category_operator' => select::EQUAL_TO, + 'course:category_value' => $coursecategory1->id, + ]; + $tablerows = $this->get_report_table_rows($filtervalues); + $this->assertEquals([ + 'Course 1', + ], array_column($tablerows, 'fullname')); + + // Filter by group mode field. + $filtervalues = [ + 'course:groupmode_operator' => select::EQUAL_TO, + 'course:groupmode_value' => SEPARATEGROUPS, + ]; + $tablerows = $this->get_report_table_rows($filtervalues); + $this->assertEquals([ + 'Course 2', + ], array_column($tablerows, 'fullname')); + + // Filter by enable completion field. + $filtervalues = [ + 'course:enablecompletion_operator' => boolean_select::CHECKED, + ]; + $tablerows = $this->get_report_table_rows($filtervalues); + $this->assertEquals([ + 'Course 1', + ], array_column($tablerows, 'fullname')); + } + + /** + * Test filtering report by course customfield + */ + public function test_customfield_text_filter(): void { + $this->resetAfterTest(); + + $course1 = $this->getDataGenerator()->create_course([ + 'fullname' => 'Philosophy and Superheroes', + 'shortname' => 'PS1', + ]); + $course2 = $this->getDataGenerator()->create_course([ + 'fullname' => 'The game of Mathematics', + 'shortname' => 'GM1', + ]); + + // Add some customfields to the course. + $cfgenerator = self::getDataGenerator()->get_plugin_generator('core_customfield'); + $params = [ + 'component' => 'core_course', + 'area' => 'course', + 'itemid' => 0, + 'contextid' => context_system::instance()->id + ]; + $category = $cfgenerator->create_category($params); + $field = $cfgenerator->create_field( + ['categoryid' => $category->get('id'), 'type' => 'text', 'name' => 'Customfield text 1', 'shortname' => 'cf']); + $cfgenerator->add_instance_data($field, (int)$course1->id, 'Leia Organa'); + $cfgenerator->add_instance_data($field, (int)$course2->id, 'Han Solo'); + $field5 = $cfgenerator->create_field( + ['categoryid' => $category->get('id'), 'type' => 'select', 'name' => 'Customfield menu', 'shortname' => 'cf5', + 'configdata' => ['defaultvalue' => 'Option A', 'options' => "Option A\nOption B\nOption C"]]); + $cfgenerator->add_instance_data($field5, (int)$course1->id, 3); + $cfgenerator->add_instance_data($field5, (int)$course2->id, 2); + + $filtervalues = [ + 'course:customfield_cf_operator' => text::IS_EQUAL_TO, + 'course:customfield_cf_value' => 'Leia Organa', + ]; + $tablerows = $this->get_report_table_rows($filtervalues); + $this->assertEquals([ + 'Philosophy and Superheroes', + ], array_column($tablerows, 'fullname')); + + $filtervalues = [ + 'course:customfield_cf_operator' => text::IS_EQUAL_TO, + 'course:customfield_cf_value' => 'Han Solo', + ]; + $tablerows = $this->get_report_table_rows($filtervalues); + $this->assertEquals([ + 'The game of Mathematics', + ], array_column($tablerows, 'fullname')); + + // Filter by menu customfield. + $filtervalues = [ + 'course:customfield_cf5_operator' => select::EQUAL_TO, + 'course:customfield_cf5_value' => 3, // Option C. + ]; + $tablerows = $this->get_report_table_rows($filtervalues); + $this->assertEquals([ + 'Philosophy and Superheroes', + ], array_column($tablerows, 'fullname')); + + // Filter by course customfield that doesn't exist. + $filtervalues = [ + 'course:customfield_cf_operator' => text::IS_EQUAL_TO, + 'course:customfield_cf_value' => 'Luke Skywalker', + ]; + $tablerows = $this->get_report_table_rows($filtervalues); + $this->assertEmpty($tablerows); + } + + /** + * Helper method to create the report, and return it's rows + * + * @param array $filtervalues + * @return array + */ + private function get_report_table_rows(array $filtervalues = []): array { + $report = manager::create_report_persistent((object) [ + 'type' => course_entity_report::TYPE_SYSTEM_REPORT, + 'source' => course_entity_report::class, + ]); + + user_filter_manager::set($report->get('id'), $filtervalues); + + return testable_system_report_table::create($report->get('id'), [])->get_table_rows(); + } + + /** + * Test entity table alias + */ + public function test_table_alias(): void { + $courseentity = new course(); + + $this->assertEquals('c', $courseentity->get_table_alias('course')); + + $courseentity->set_table_alias('course', 'newalias'); + $this->assertEquals('newalias', $courseentity->get_table_alias('course')); + } + + /** + * Test for invalid get table alias + */ + public function test_get_table_alias_invalid(): void { + $courseentity = new course(); + + $this->expectException(coding_exception::class); + $this->expectExceptionMessage('Coding error detected, it must be fixed by a programmer: ' . + 'Invalid table name (nonexistingalias)'); + $courseentity->get_table_alias('nonexistingalias'); + } + + /** + * Test invalid entity set table alias + */ + public function test_set_table_alias_invalid(): void { + $courseentity = new course(); + + $this->expectException(coding_exception::class); + $this->expectExceptionMessage('Coding error detected, it must be fixed by a programmer: Invalid table name (nonexistent)'); + $courseentity->set_table_alias('nonexistent', 'newalias'); + } + + /** + * Test entity name + */ + public function test_name(): void { + $courseentity = new course(); + + $this->assertEquals('course', $courseentity->get_entity_name()); + + $courseentity->set_entity_name('newentityname'); + $this->assertEquals('newentityname', $courseentity->get_entity_name()); + } + + /** + * Test invalid entity name + */ + public function test_name_invalid(): void { + $courseentity = new course(); + + $this->expectException(coding_exception::class); + $this->expectExceptionMessage('Entity name must be comprised of alphanumeric character, underscore or dash'); + $courseentity->set_entity_name(''); + } + + /** + * Test entity title + */ + public function test_title(): void { + $courseentity = new course(); + + $this->assertEquals(new lang_string('entitycourse', 'core_reportbuilder'), $courseentity->get_entity_title()); + + $newtitle = new lang_string('fullname'); + $courseentity->set_entity_title($newtitle); + $this->assertEquals($newtitle, $courseentity->get_entity_title()); + } + + /** + * Test adding single join + */ + public function test_add_join(): void { + $courseentity = (new course()) + ->set_table_alias('course', 'c1'); + + $tablejoin = "JOIN {course} c2 ON c2.id = c1.id"; + $courseentity->add_join($tablejoin); + + $method = (new ReflectionClass(course::class))->getMethod('get_joins'); + $method->setAccessible(true); + $this->assertEquals([$tablejoin], $method->invoke($courseentity)); + } + + /** + * Test adding multiple join + */ + public function test_add_joins(): void { + $courseentity = (new course()) + ->set_table_alias('course', 'c1'); + + $tablejoins = [ + "JOIN {course} c2 ON c2.id = c1.id", + "JOIN {course} c3 ON c3.id = c1.id", + ]; + $courseentity->add_joins($tablejoins); + + $method = (new ReflectionClass(course::class))->getMethod('get_joins'); + $method->setAccessible(true); + $this->assertEquals($tablejoins, $method->invoke($courseentity)); + } + + /** + * Test for invalid get column + */ + public function test_get_column_invalid(): void { + $courseentity = new course(); + + $this->expectException(coding_exception::class); + $this->expectExceptionMessage('Coding error detected, it must be fixed by a programmer: ' . + 'Invalid column name (nonexistingcolumn)'); + $courseentity->get_column('nonexistingcolumn'); + } + + /** + * Test for invalid get filter + */ + public function test_get_filter_invalid(): void { + $courseentity = new course(); + + $this->expectException(coding_exception::class); + $this->expectExceptionMessage('Coding error detected, it must be fixed by a programmer: ' . + 'Invalid filter name (nonexistingfilter)'); + $courseentity->get_filter('nonexistingfilter'); + } +} diff --git a/reportbuilder/tests/local/entities/user_test.php b/reportbuilder/tests/local/entities/user_test.php new file mode 100644 index 00000000000..4ba2762da67 --- /dev/null +++ b/reportbuilder/tests/local/entities/user_test.php @@ -0,0 +1,290 @@ +. + +declare(strict_types=1); + +namespace core_reportbuilder\local\entities; + +use advanced_testcase; +use core_reportbuilder\local\filters\boolean_select; +use core_reportbuilder\local\filters\date; +use moodle_url; +use core_reportbuilder\manager; +use core_reportbuilder\testable_system_report_table; +use core_reportbuilder\user_entity_report; +use core_reportbuilder\local\filters\text; +use core_reportbuilder\local\helpers\user_filter_manager; + +/** + * Unit tests for user entity + * + * @package core_reportbuilder + * @covers \core_reportbuilder\local\entities\base + * @covers \core_reportbuilder\local\entities\user + * @covers \core_reportbuilder\local\helpers\user_profile_fields + * @covers \core_reportbuilder\local\report\base + * @covers \core_reportbuilder\system_report + * @copyright 2021 Paul Holden + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class user_testcase extends advanced_testcase { + + /** + * Load required classes + */ + public static function setUpBeforeClass(): void { + global $CFG; + + require_once("{$CFG->dirroot}/reportbuilder/tests/fixtures/testable_system_report_table.php"); + require_once("{$CFG->dirroot}/reportbuilder/tests/fixtures/user_entity_report.php"); + require_once("{$CFG->dirroot}/user/profile/lib.php"); + } + + /** + * Test callbacks are correctly applied for those columns using them + */ + public function test_columns_with_callbacks(): void { + $this->resetAfterTest(); + + // Add a couple of user profile fields to show on the report. + $this->getDataGenerator()->create_custom_profile_field(['datatype' => 'text', + 'shortname' => 'favcolor', 'name' => 'Favorite color']); + $this->getDataGenerator()->create_custom_profile_field(['datatype' => 'text', + 'shortname' => 'favsuperpower', 'name' => 'Favorite super power']); + + $user = $this->getDataGenerator()->create_user([ + 'suspended' => 1, + 'confirmed' => 0, + 'country' => 'ES', + 'profile_field_favcolor' => 'Blue', + 'profile_field_favsuperpower' => 'Time travel', + ]); + + $tablerows = $this->get_report_table_rows(); + $userrows = array_filter($tablerows, static function(array $row) use ($user): bool { + return $row['username'] === $user->username; + }); + $userrow = reset($userrows); + + $this->assertEquals('Yes', $userrow['suspended']); + $this->assertEquals('No', $userrow['confirmed']); + $this->assertEquals('Spain', $userrow['country']); + $this->assertEquals('Blue', $userrow['profilefield_favcolor']); + $this->assertEquals('Time travel', $userrow['profilefield_favsuperpower']); + } + + /** + * Test the formatted user fullname columns + */ + public function test_fullname_columns(): void { + global $OUTPUT; + + $this->resetAfterTest(); + + $user = $this->getDataGenerator()->create_user([]); + + $tablerows = $this->get_report_table_rows(); + $userrows = array_filter($tablerows, static function(array $row) use ($user): bool { + return $row['username'] === $user->username; + }); + $userrow = reset($userrows); + + $userfullname = fullname($user); + $userprofile = (new moodle_url('/user/profile.php', ['id' => $user->id]))->out(); + $userpicture = $OUTPUT->user_picture($user, ['link' => false, 'alttext' => false]); + + $this->assertEquals($userfullname, $userrow['fullname']); + $this->assertEquals('' . $userfullname . '', $userrow['fullnamewithlink']); + $this->assertEquals($userpicture . $userfullname, $userrow['fullnamewithpicture']); + $this->assertEquals('' . $userpicture . $userfullname . '', + $userrow['fullnamewithpicturelink']); + } + + /** + * Test picture column callback + */ + public function test_picture_column(): void { + global $OUTPUT; + + $this->resetAfterTest(); + + $user = $this->getDataGenerator()->create_user([]); + + $tablerows = $this->get_report_table_rows(); + $userrows = array_filter($tablerows, static function(array $row) use ($user): bool { + return $row['username'] === $user->username; + }); + $userrow = reset($userrows); + + $userpicture = $OUTPUT->user_picture($user, ['link' => false, 'alttext' => false]); + $this->assertEquals($userpicture, $userrow['picture']); + } + + /** + * Test filtering report by user fields + */ + public function test_filters(): void { + $this->resetAfterTest(); + + $this->getDataGenerator()->create_user(['firstname' => 'Daffy', 'lastname' => 'Duck', 'email' => 'daffy@test.com', + 'city' => 'LA', 'lastaccess' => time() - YEARSECS, 'suspended' => 1]); + $this->getDataGenerator()->create_user(['firstname' => 'Donald', 'lastname' => 'Duck', 'email' => 'donald@test.com', + 'city' => 'Chicago', 'lastaccess' => time(), 'suspended' => 0]); + + // Filter by fullname field. + $filtervalues = [ + 'user:fullname_operator' => text::IS_EQUAL_TO, + 'user:fullname_value' => 'Daffy Duck', + ]; + $tablerows = $this->get_report_table_rows($filtervalues); + $this->assertEquals([ + 'Daffy Duck', + ], array_column($tablerows, 'fullname')); + + // Filter by firstname field. + $filtervalues = [ + 'user:firstname_operator' => text::CONTAINS, + 'user:firstname_value' => 'Donald', + ]; + $tablerows = $this->get_report_table_rows($filtervalues); + $this->assertEquals([ + 'Donald Duck', + ], array_column($tablerows, 'fullname')); + + // Filter by lastname field. + $filtervalues = [ + 'user:lastname_operator' => text::CONTAINS, + 'user:lastname_value' => 'Duck', + ]; + $tablerows = $this->get_report_table_rows($filtervalues); + $this->assertEqualsCanonicalizing([ + 'Donald Duck', + 'Daffy Duck', + ], array_column($tablerows, 'fullname')); + + // Filter by email field. + $filtervalues = [ + 'user:email_operator' => text::IS_EQUAL_TO, + 'user:email_value' => 'donald@test.com', + ]; + $tablerows = $this->get_report_table_rows($filtervalues); + $this->assertEquals([ + 'Donald Duck', + ], array_column($tablerows, 'fullname')); + + // Filter by city field. + $filtervalues = [ + 'user:city_operator' => text::IS_EQUAL_TO, + 'user:city_value' => 'Chicago', + ]; + $tablerows = $this->get_report_table_rows($filtervalues); + $this->assertEquals([ + 'Donald Duck', + ], array_column($tablerows, 'fullname')); + + // Filter by city field. + $filtervalues = [ + 'user:city_operator' => text::IS_EQUAL_TO, + 'user:city_value' => 'Chicago', + ]; + $tablerows = $this->get_report_table_rows($filtervalues); + $this->assertEquals([ + 'Donald Duck', + ], array_column($tablerows, 'fullname')); + + // Filter by lastaccess field. + $filtervalues = [ + 'user:lastaccess_operator' => date::DATE_RANGE, + 'user:lastaccess_from' => time() - YEARSECS - 100, + 'user:lastaccess_to' => time() - YEARSECS + 100, + ]; + $tablerows = $this->get_report_table_rows($filtervalues); + $this->assertEquals([ + 'Daffy Duck', + ], array_column($tablerows, 'fullname')); + + // Filter by suspened field. + $filtervalues = [ + 'user:suspended_operator' => boolean_select::CHECKED, + ]; + $tablerows = $this->get_report_table_rows($filtervalues); + $this->assertEquals([ + 'Daffy Duck', + ], array_column($tablerows, 'fullname')); + } + + /** + * Test filtering report by a user profile field + */ + public function test_userprofilefield_filter(): void { + $this->resetAfterTest(); + + // Add a user profile field to show on the report. + $this->getDataGenerator()->create_custom_profile_field(['datatype' => 'text', + 'shortname' => 'favcolor', 'name' => 'Favorite color']); + + $this->getDataGenerator()->create_user(['firstname' => 'Daffy', 'lastname' => 'Duck', 'profile_field_favcolor' => 'Blue']); + $this->getDataGenerator()->create_user(['firstname' => 'Donald', 'lastname' => 'Duck', + 'profile_field_favcolor' => 'Green']); + + $filtervalues = [ + 'user:profilefield_favcolor_operator' => text::IS_EQUAL_TO, + 'user:profilefield_favcolor_value' => 'Green', + ]; + + $tablerows = $this->get_report_table_rows($filtervalues); + $this->assertEquals([ + 'Donald Duck', + ], array_column($tablerows, 'fullname')); + } + + /** + * Tests the helper method for selecting all of a users' name fields + */ + public function test_get_name_fields_select(): void { + global $DB; + + $fields = user::get_name_fields_select('u'); + $user = $DB->get_record_sql("SELECT {$fields} FROM {user} u WHERE username = :username", ['username' => 'admin']); + + // Ensure we received back all name fields. + $this->assertEqualsCanonicalizing([ + 'firstname', + 'lastname', + 'firstnamephonetic', + 'lastnamephonetic', + 'middlename', + 'alternatename', + ], array_keys((array) $user)); + } + + /** + * Helper method to create the report, and return it's rows + * + * @param array $filtervalues + * @return array + */ + private function get_report_table_rows(array $filtervalues = []): array { + $report = manager::create_report_persistent((object) [ + 'type' => user_entity_report::TYPE_SYSTEM_REPORT, + 'source' => user_entity_report::class, + ]); + + user_filter_manager::set($report->get('id'), $filtervalues); + + return testable_system_report_table::create($report->get('id'), [])->get_table_rows(); + } +} diff --git a/reportbuilder/tests/local/helpers/custom_fields_test.php b/reportbuilder/tests/local/helpers/custom_fields_test.php new file mode 100644 index 00000000000..fcfcb8932a1 --- /dev/null +++ b/reportbuilder/tests/local/helpers/custom_fields_test.php @@ -0,0 +1,133 @@ +. + +declare(strict_types=1); + +namespace core_reportbuilder\local\helpers; + +use advanced_testcase; +use core_reportbuilder\local\entities\course; +use core_reportbuilder\local\report\column; +use core_reportbuilder\local\report\filter; + +/** + * Unit tests for custom fields helper + * + * @package core_reportbuilder + * @covers \core_reportbuilder\local\helpers\custom_fields + * @copyright 2021 David Matamoros + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class custom_fields_testcase extends advanced_testcase { + + /** + * Generate a course with customfields + */ + public function generate_course_with_customfields(): custom_fields { + $course = $this->getDataGenerator()->create_course(); + + // Add some customfields to the course. + $cfgenerator = self::getDataGenerator()->get_plugin_generator('core_customfield'); + $params = [ + 'component' => 'core_course', + 'area' => 'course', + 'itemid' => 0, + 'contextid' => \context_system::instance()->id + ]; + $category = $cfgenerator->create_category($params); + $field1 = $cfgenerator->create_field( + ['categoryid' => $category->get('id'), 'type' => 'text', 'name' => 'Customfield text 1', 'shortname' => 'cf1']); + $cfgenerator->add_instance_data($field1, (int)$course->id, 'C-3PO'); + $field2 = $cfgenerator->create_field( + ['categoryid' => $category->get('id'), 'type' => 'text', 'name' => 'Customfield text 2', 'shortname' => 'cf2']); + $cfgenerator->add_instance_data($field2, (int)$course->id, 'R2-D2'); + + $courseentity = new course(); + $coursealias = $courseentity->get_table_alias('course'); + + // Create an instance of the customfields helper. + return new custom_fields($coursealias . '.id', $courseentity->get_entity_name(), + 'core_course', 'course'); + } + + /** + * Test for get_columns + */ + public function test_get_columns(): void { + $this->resetAfterTest(); + + $customfields = $this->generate_course_with_customfields(); + $columns = $customfields->get_columns(); + $this->assertCount(2, $columns); + [$column0, $column1] = $columns; + $this->assertInstanceOf(column::class, $column0); + $this->assertInstanceOf(column::class, $column1); + $this->assertEqualsCanonicalizing(['Customfield text 1', 'Customfield text 2'], + [$column0->get_title(), $column1->get_title()]); + $this->assertEquals(column::TYPE_TEXT, $column0->get_type()); + $this->assertEquals('course', $column0->get_entity_name()); + $this->assertStringStartsWith('LEFT JOIN {customfield_data}', $column0->get_joins()[0]); + // Column of type TEXT is sortable. + $this->assertTrue($column0->get_is_sortable()); + } + + /** + * Test for add_join + */ + public function test_add_join(): void { + $this->resetAfterTest(); + + $customfields = $this->generate_course_with_customfields(); + $columns = $customfields->get_columns(); + $this->assertCount(1, ($columns[0])->get_joins()); + + $customfields->add_join('JOIN {test} t ON t.id = id'); + $columns = $customfields->get_columns(); + $this->assertCount(2, ($columns[0])->get_joins()); + } + + /** + * Test for add_joins + */ + public function test_add_joins(): void { + $this->resetAfterTest(); + + $customfields = $this->generate_course_with_customfields(); + $columns = $customfields->get_columns(); + $this->assertCount(1, ($columns[0])->get_joins()); + + $customfields->add_joins(['JOIN {test} t ON t.id = id', 'JOIN {test2} t2 ON t2.id = id']); + $columns = $customfields->get_columns(); + $this->assertCount(3, ($columns[0])->get_joins()); + } + + /** + * Test for get_filters + */ + public function test_get_filters(): void { + $this->resetAfterTest(); + + $customfields = $this->generate_course_with_customfields(); + $filters = $customfields->get_filters(); + $this->assertCount(2, $filters); + [$filter0, $filter1] = $filters; + $this->assertInstanceOf(filter::class, $filter0); + $this->assertInstanceOf(filter::class, $filter1); + $this->assertEqualsCanonicalizing(['Customfield text 1', 'Customfield text 2'], + [$filter0->get_header(), $filter1->get_header()]); + } +} + diff --git a/reportbuilder/tests/local/helpers/user_profile_fields_test.php b/reportbuilder/tests/local/helpers/user_profile_fields_test.php new file mode 100644 index 00000000000..c73f41f470e --- /dev/null +++ b/reportbuilder/tests/local/helpers/user_profile_fields_test.php @@ -0,0 +1,117 @@ +. + +declare(strict_types=1); + +namespace core_reportbuilder\local\helpers; + +use advanced_testcase; +use core_reportbuilder\local\entities\user; +use core_reportbuilder\local\report\column; +use core_reportbuilder\local\report\filter; + +/** + * Unit tests for user profile fields helper + * + * @package core_reportbuilder + * @covers \core_reportbuilder\local\helpers\user_profile_fields + * @copyright 2021 David Matamoros + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class user_profile_fields_test_testcase extends advanced_testcase { + + /** + * Generate userprofilefields + */ + private function generate_userprofilefields(): user_profile_fields { + $this->getDataGenerator()->create_custom_profile_field([ + 'shortname' => 'upf1', 'name' => 'User profile field text 1', 'datatype' => 'text']); + + $this->getDataGenerator()->create_custom_profile_field([ + 'shortname' => 'upf2', 'name' => 'User profile field text 2', 'datatype' => 'text']); + + $userentity = new user(); + $useralias = $userentity->get_table_alias('user'); + + // Create an instance of the userprofilefield helper. + return new user_profile_fields("$useralias.id", $userentity->get_entity_name()); + } + + /** + * Test for get_columns + */ + public function test_get_columns(): void { + $this->resetAfterTest(); + + $userprofilefields = $this->generate_userprofilefields(); + $columns = $userprofilefields->get_columns(); + $this->assertCount(2, $columns); + [$column0, $column1] = $columns; + $this->assertInstanceOf(column::class, $column0); + $this->assertInstanceOf(column::class, $column1); + $this->assertEqualsCanonicalizing(['User profile field text 1', 'User profile field text 2'], + [$column0->get_title(), $column1->get_title()]); + $this->assertEquals(column::TYPE_TEXT, $column0->get_type()); + $this->assertEquals('user', $column0->get_entity_name()); + $this->assertStringStartsWith('LEFT JOIN {user_info_data}', $column0->get_joins()[0]); + } + + /** + * Test for add_join + */ + public function test_add_join(): void { + $this->resetAfterTest(); + + $userprofilefields = $this->generate_userprofilefields(); + $columns = $userprofilefields->get_columns(); + $this->assertCount(1, ($columns[0])->get_joins()); + + $userprofilefields->add_join('JOIN {test} t ON t.id = id'); + $columns = $userprofilefields->get_columns(); + $this->assertCount(2, ($columns[0])->get_joins()); + } + + /** + * Test for add_joins + */ + public function test_add_joins(): void { + $this->resetAfterTest(); + + $userprofilefields = $this->generate_userprofilefields(); + $columns = $userprofilefields->get_columns(); + $this->assertCount(1, ($columns[0])->get_joins()); + + $userprofilefields->add_joins(['JOIN {test} t ON t.id = id', 'JOIN {test2} t2 ON t2.id = id']); + $columns = $userprofilefields->get_columns(); + $this->assertCount(3, ($columns[0])->get_joins()); + } + + /** + * Test for get_filters + */ + public function test_get_filters(): void { + $this->resetAfterTest(); + + $userprofilefields = $this->generate_userprofilefields(); + $filters = $userprofilefields->get_filters(); + $this->assertCount(2, $filters); + [$filter0, $filter1] = $filters; + $this->assertInstanceOf(filter::class, $filter0); + $this->assertInstanceOf(filter::class, $filter1); + $this->assertEqualsCanonicalizing(['User profile field text 1', 'User profile field text 2'], + [$filter0->get_header(), $filter1->get_header()]); + } +} From 9f814ffec0ae89d92d44c5191dbed25b98377894 Mon Sep 17 00:00:00 2001 From: David Matamoros Date: Fri, 16 Apr 2021 09:42:38 +0200 Subject: [PATCH 7/9] MDL-70794 reportbuilder: privacy provider implementation. Note that although system report persistents store user modified and created fields, these are not the values of the user who did either. Merely the user who first viewed the report. --- lang/en/reportbuilder.php | 5 + reportbuilder/classes/privacy/provider.php | 72 +++++++++++ reportbuilder/tests/privacy/provider_test.php | 112 ++++++++++++++++++ 3 files changed, 189 insertions(+) create mode 100644 reportbuilder/classes/privacy/provider.php create mode 100644 reportbuilder/tests/privacy/provider_test.php diff --git a/lang/en/reportbuilder.php b/lang/en/reportbuilder.php index 0aab51ba3df..1bdb8a76232 100644 --- a/lang/en/reportbuilder.php +++ b/lang/en/reportbuilder.php @@ -54,6 +54,11 @@ $string['filterrange'] = 'Range'; $string['filtersapplied'] = 'Filters applied'; $string['filtersreset'] = 'Filters reset'; $string['filterstartswith'] = 'Starts with'; +$string['privacy:metadata:preference:reportfilter'] = 'Stored report filter values'; +$string['privacy:metadata:report'] = 'Report definitions'; +$string['privacy:metadata:report:name'] = 'The name of the report'; +$string['privacy:metadata:report:usercreated'] = 'The ID of the user who created the report'; +$string['privacy:metadata:report:usermodified'] = 'The ID of the user who last modified the report'; $string['resetall'] = 'Reset all'; $string['selectcourses'] = 'Select courses'; $string['userfullnamewithlink'] = 'Full name with link'; diff --git a/reportbuilder/classes/privacy/provider.php b/reportbuilder/classes/privacy/provider.php new file mode 100644 index 00000000000..a260154e716 --- /dev/null +++ b/reportbuilder/classes/privacy/provider.php @@ -0,0 +1,72 @@ +. + +declare(strict_types=1); + +namespace core_reportbuilder\privacy; + +use core_privacy\local\metadata\collection; +use core_privacy\local\request\writer; +use core_reportbuilder\local\helpers\user_filter_manager; +use core_reportbuilder\local\models\report; + +/** + * Privacy Subsystem for core_reportbuilder + * + * @package core_reportbuilder + * @copyright 2021 David Matamoros + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class provider implements + \core_privacy\local\metadata\provider, + \core_privacy\local\request\user_preference_provider { + + /** + * Returns metadata about the component + * + * @param collection $collection + * @return collection + */ + public static function get_metadata(collection $collection): collection { + $collection->add_database_table(report::TABLE, [ + 'name' => 'privacy:metadata:report:name', + 'usercreated' => 'privacy:metadata:report:usercreated', + 'usermodified' => 'privacy:metadata:report:usermodified', + ], 'privacy:metadata:report'); + + $collection->add_user_preference('core_reportbuilder', 'privacy:metadata:preference:reportfilter'); + + return $collection; + } + + /** + * Export all user preferences for the component + * + * @param int $userid + */ + public static function export_user_preferences(int $userid): void { + $preferencestring = get_string('privacy:metadata:preference:reportfilter', 'core_reportbuilder'); + + $filters = user_filter_manager::get_all_for_user($userid); + foreach ($filters as $key => $filter) { + writer::export_user_preference('core_reportbuilder', + $key, + json_encode($filter, JSON_PRETTY_PRINT), + $preferencestring + ); + } + } +} diff --git a/reportbuilder/tests/privacy/provider_test.php b/reportbuilder/tests/privacy/provider_test.php new file mode 100644 index 00000000000..34f54b9212b --- /dev/null +++ b/reportbuilder/tests/privacy/provider_test.php @@ -0,0 +1,112 @@ +. + +declare(strict_types=1); + +namespace core_reportbuilder\privacy; + +use context_system; +use core_privacy\local\metadata\collection; +use core_privacy\local\metadata\types\database_table; +use core_privacy\local\metadata\types\user_preference; +use core_privacy\local\request\writer; +use core_reportbuilder\manager; +use core_reportbuilder\local\helpers\user_filter_manager; + +/** + * Unit tests for privacy provider + * + * @package core_reportbuilder + * @covers \core_reportbuilder\privacy\provider + * @copyright 2021 David Matamoros + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class provider_testcase extends \core_privacy\tests\provider_testcase { + + /** + * Test provider metadata + */ + public function test_get_metadata(): void { + $collection = new collection('core_reportbuilder'); + $metadata = provider::get_metadata($collection)->get_collection(); + + $this->assertCount(2, $metadata); + + $this->assertInstanceOf(database_table::class, $metadata[0]); + $this->assertEquals('reportbuilder_report', $metadata[0]->get_name()); + + $this->assertInstanceOf(user_preference::class, $metadata[1]); + } + + /** + * Test to check export_user_preferences. + */ + public function test_export_user_preferences(): void { + $this->resetAfterTest(); + + $user1 = $this->getDataGenerator()->create_user(); + $user2 = $this->getDataGenerator()->create_user(); + $this->setUser($user1); + + // Create report and set some filters for the user. + $report1 = manager::create_report_persistent((object) [ + 'type' => 1, + 'source' => 'class', + ]); + $filtervalues1 = [ + 'task_log:name_operator' => 0, + 'task_log:name_value' => 'My task logs', + ]; + user_filter_manager::set($report1->get('id'), $filtervalues1); + + // Add a filter for user2. + $filtervalues1user2 = [ + 'task_log:name_operator' => 0, + 'task_log:name_value' => 'My task logs user2', + ]; + user_filter_manager::set($report1->get('id'), $filtervalues1user2, (int)$user2->id); + + // Create a second report and set some filters for the user. + $report2 = manager::create_report_persistent((object) [ + 'type' => 1, + 'source' => 'class', + ]); + $filtervalues2 = [ + 'config_change:setting_operator' => 0, + 'config_change:setting_value' => str_repeat('A', 3000), + ]; + user_filter_manager::set($report2->get('id'), $filtervalues2); + + // Switch to admin user (so we can validate preferences of our test user are still exported). + $this->setAdminUser(); + + // Export user preferences. + provider::export_user_preferences((int)$user1->id); + $writer = writer::with_context(context_system::instance()); + $prefs = $writer->get_user_preferences('core_reportbuilder'); + + // Check that user preferences only contain the 2 preferences from user1. + $this->assertCount(2, (array)$prefs); + + // Check that exported user preferences for report1 are correct. + $report1key = 'reportbuilder-report-' . $report1->get('id'); + $this->assertEquals(json_encode($filtervalues1, JSON_PRETTY_PRINT), $prefs->$report1key->value); + + // Check that exported user preferences for report2 are correct. + $report2key = 'reportbuilder-report-' . $report2->get('id'); + $this->assertEquals(json_encode($filtervalues2, JSON_PRETTY_PRINT), $prefs->$report2key->value); + } +} From ef3c605b02c10281a8310f3ad0e2be0e0950daf4 Mon Sep 17 00:00:00 2001 From: Paul Holden Date: Tue, 20 Apr 2021 09:01:21 +0000 Subject: [PATCH 8/9] MDL-70794 reportbuilder: user interface for using report filters. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The report filter form is implemented as a dynamic form. Implement accompanying AMD module for interacting with it here (applying and resetting report filters). Co-Authored-By: Mikel Martín --- lib/db/services.php | 6 ++ reportbuilder/amd/build/filters.min.js | 2 + reportbuilder/amd/build/filters.min.js.map | 1 + .../amd/build/local/repository/filters.min.js | 2 + .../build/local/repository/filters.min.js.map | 1 + reportbuilder/amd/src/filters.js | 76 +++++++++++++++++++ .../amd/src/local/repository/filters.js | 40 ++++++++++ reportbuilder/classes/form/filter.php | 5 ++ .../templates/local/filters/area.mustache | 53 +++++++++++++ .../templates/local/filters/footer.mustache | 26 +++++++ .../templates/local/filters/header.mustache | 32 ++++++++ .../templates/system_report.mustache | 3 + .../tests/behat/behat_reportbuilder.php | 43 +++++++++++ 13 files changed, 290 insertions(+) create mode 100644 reportbuilder/amd/build/filters.min.js create mode 100644 reportbuilder/amd/build/filters.min.js.map create mode 100644 reportbuilder/amd/build/local/repository/filters.min.js create mode 100644 reportbuilder/amd/build/local/repository/filters.min.js.map create mode 100644 reportbuilder/amd/src/filters.js create mode 100644 reportbuilder/amd/src/local/repository/filters.js create mode 100644 reportbuilder/templates/local/filters/area.mustache create mode 100644 reportbuilder/templates/local/filters/footer.mustache create mode 100644 reportbuilder/templates/local/filters/header.mustache create mode 100644 reportbuilder/tests/behat/behat_reportbuilder.php diff --git a/lib/db/services.php b/lib/db/services.php index d796bacb1d3..89b5fe07e61 100644 --- a/lib/db/services.php +++ b/lib/db/services.php @@ -2773,6 +2773,12 @@ $functions = array( 'type' => 'read', 'ajax' => true, ], + 'core_reportbuilder_filters_reset' => [ + 'classname' => 'core_reportbuilder\external\filters\reset', + 'description' => 'Reset filters for given report', + 'type' => 'write', + 'ajax' => true, + ], ); $services = array( diff --git a/reportbuilder/amd/build/filters.min.js b/reportbuilder/amd/build/filters.min.js new file mode 100644 index 00000000000..de33701836d --- /dev/null +++ b/reportbuilder/amd/build/filters.min.js @@ -0,0 +1,2 @@ +function _typeof(a){"@babel/helpers - typeof";if("function"==typeof Symbol&&"symbol"==typeof Symbol.iterator){_typeof=function(a){return typeof a}}else{_typeof=function(a){return a&&"function"==typeof Symbol&&a.constructor===Symbol&&a!==Symbol.prototype?"symbol":typeof a}}return _typeof(a)}define ("core_reportbuilder/filters",["exports","core/event_dispatcher","core/notification","core/pending","core/str","core/toast","core_form/dynamicform","core_reportbuilder/local/events","core_reportbuilder/local/selectors","core_reportbuilder/local/repository/filters"],function(a,b,c,d,e,f,g,h,i,j){"use strict";Object.defineProperty(a,"__esModule",{value:!0});a.init=void 0;c=m(c);d=m(d);g=m(g);h=l(h);i=l(i);function k(){if("function"!=typeof WeakMap)return null;var a=new WeakMap;k=function(){return a};return a}function l(a){if(a&&a.__esModule){return a}if(null===a||"object"!==_typeof(a)&&"function"!=typeof a){return{default:a}}var b=k();if(b&&b.has(a)){return b.get(a)}var c={},d=Object.defineProperty&&Object.getOwnPropertyDescriptor;for(var e in a){if(Object.prototype.hasOwnProperty.call(a,e)){var f=d?Object.getOwnPropertyDescriptor(a,e):null;if(f&&(f.get||f.set)){Object.defineProperty(c,e,f)}else{c[e]=a[e]}}}c.default=a;if(b){b.set(a,c)}return c}function m(a){return a&&a.__esModule?a:{default:a}}var n=function(a){var k=document.querySelector(i.forSystemReport(a)),l=k.querySelector(i.regions.filtersForm),m=new g.default(l,"\\core_reportbuilder\\form\\filter");m.addEventListener(m.events.FORM_SUBMITTED,function(a){a.preventDefault();(0,b.dispatchEvent)(h.tableReload,{},k);(0,e.get_string)("filtersapplied","core_reportbuilder").then(f.add).catch(c.default.exception)});m.addEventListener(m.events.NOSUBMIT_BUTTON_PRESSED,function(b){b.preventDefault();var g=new d.default("core_reportbuilder/filters:reset");(0,j.reset)(a).then(function(){return(0,e.get_string)("filtersreset","core_reportbuilder")}).then(f.add).then(function(){g.resolve();window.location.reload()}).catch(c.default.exception)});document.querySelector("#region-main").style.overflowX="visible"};a.init=n}); +//# sourceMappingURL=filters.min.js.map diff --git a/reportbuilder/amd/build/filters.min.js.map b/reportbuilder/amd/build/filters.min.js.map new file mode 100644 index 00000000000..b93418bfc43 --- /dev/null +++ b/reportbuilder/amd/build/filters.min.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["../src/filters.js"],"names":["init","reportId","reportElement","document","querySelector","reportSelectors","forSystemReport","filterFormContainer","regions","filtersForm","filterForm","DynamicForm","addEventListener","events","FORM_SUBMITTED","event","preventDefault","reportEvents","tableReload","then","addToast","catch","Notification","exception","NOSUBMIT_BUTTON_PRESSED","pendingPromise","Pending","resolve","window","location","reload","style","overflowX"],"mappings":"8pBAyBA,OACA,OAGA,OACA,OACA,O,ylBAQO,GAAMA,CAAAA,CAAI,CAAG,SAAAC,CAAQ,CAAI,IACtBC,CAAAA,CAAa,CAAGC,QAAQ,CAACC,aAAT,CAAuBC,CAAe,CAACC,eAAhB,CAAgCL,CAAhC,CAAvB,CADM,CAEtBM,CAAmB,CAAGL,CAAa,CAACE,aAAd,CAA4BC,CAAe,CAACG,OAAhB,CAAwBC,WAApD,CAFA,CAGtBC,CAAU,CAAG,GAAIC,UAAJ,CAAgBJ,CAAhB,CAAqC,oCAArC,CAHS,CAM5BG,CAAU,CAACE,gBAAX,CAA4BF,CAAU,CAACG,MAAX,CAAkBC,cAA9C,CAA8D,SAAAC,CAAK,CAAI,CACnEA,CAAK,CAACC,cAAN,GAGA,oBAAcC,CAAY,CAACC,WAA3B,CAAwC,EAAxC,CAA4ChB,CAA5C,EAEA,iBAAU,gBAAV,CAA4B,oBAA5B,EACKiB,IADL,CACUC,KADV,EAEKC,KAFL,CAEWC,UAAaC,SAFxB,CAGH,CATD,EAYAb,CAAU,CAACE,gBAAX,CAA4BF,CAAU,CAACG,MAAX,CAAkBW,uBAA9C,CAAuE,SAAAT,CAAK,CAAI,CAC5EA,CAAK,CAACC,cAAN,GAEA,GAAMS,CAAAA,CAAc,CAAG,GAAIC,UAAJ,CAAY,kCAAZ,CAAvB,CAEA,YAAazB,CAAb,EACKkB,IADL,CACU,iBAAM,iBAAU,cAAV,CAA0B,oBAA1B,CAAN,CADV,EAEKA,IAFL,CAEUC,KAFV,EAGKD,IAHL,CAGU,UAAM,CACRM,CAAc,CAACE,OAAf,GACAC,MAAM,CAACC,QAAP,CAAgBC,MAAhB,EAEH,CAPL,EAQKT,KARL,CAQWC,UAAaC,SARxB,CASH,CAdD,EAiBApB,QAAQ,CAACC,aAAT,CAAuB,cAAvB,EAAuC2B,KAAvC,CAA6CC,SAA7C,CAAyD,SAC5D,CApCM,C","sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/**\n * Report builder filter management\n *\n * @module core_reportbuilder/filters\n * @package core_reportbuilder\n * @copyright 2021 Paul Holden \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\nimport {dispatchEvent} from 'core/event_dispatcher';\nimport Notification from 'core/notification';\nimport Pending from 'core/pending';\nimport {get_string as getString} from 'core/str';\nimport {add as addToast} from 'core/toast';\nimport DynamicForm from 'core_form/dynamicform';\nimport * as reportEvents from 'core_reportbuilder/local/events';\nimport * as reportSelectors from 'core_reportbuilder/local/selectors';\nimport {reset as resetFilters} from 'core_reportbuilder/local/repository/filters';\n\n/**\n * Initialise module\n *\n * @param {Number} reportId\n */\nexport const init = reportId => {\n const reportElement = document.querySelector(reportSelectors.forSystemReport(reportId));\n const filterFormContainer = reportElement.querySelector(reportSelectors.regions.filtersForm);\n const filterForm = new DynamicForm(filterFormContainer, '\\\\core_reportbuilder\\\\form\\\\filter');\n\n // Submit report filters.\n filterForm.addEventListener(filterForm.events.FORM_SUBMITTED, event => {\n event.preventDefault();\n\n // After the form has been submitted, we should trigger report table reload.\n dispatchEvent(reportEvents.tableReload, {}, reportElement);\n\n getString('filtersapplied', 'core_reportbuilder')\n .then(addToast)\n .catch(Notification.exception);\n });\n\n // Reset report filters.\n filterForm.addEventListener(filterForm.events.NOSUBMIT_BUTTON_PRESSED, event => {\n event.preventDefault();\n\n const pendingPromise = new Pending('core_reportbuilder/filters:reset');\n\n resetFilters(reportId)\n .then(() => getString('filtersreset', 'core_reportbuilder'))\n .then(addToast)\n .then(() => {\n pendingPromise.resolve();\n window.location.reload();\n return;\n })\n .catch(Notification.exception);\n });\n\n // Modify \"region-main\" overflow for big filter forms.\n document.querySelector('#region-main').style.overflowX = \"visible\";\n};\n"],"file":"filters.min.js"} \ No newline at end of file diff --git a/reportbuilder/amd/build/local/repository/filters.min.js b/reportbuilder/amd/build/local/repository/filters.min.js new file mode 100644 index 00000000000..c68a07eddac --- /dev/null +++ b/reportbuilder/amd/build/local/repository/filters.min.js @@ -0,0 +1,2 @@ +define ("core_reportbuilder/local/repository/filters",["exports","core/ajax"],function(a,b){"use strict";Object.defineProperty(a,"__esModule",{value:!0});a.reset=void 0;b=function(a){return a&&a.__esModule?a:{default:a}}(b);var c=function(a){return b.default.call([{methodname:"core_reportbuilder_filters_reset",args:{reportid:a}}])[0]};a.reset=c}); +//# sourceMappingURL=filters.min.js.map diff --git a/reportbuilder/amd/build/local/repository/filters.min.js.map b/reportbuilder/amd/build/local/repository/filters.min.js.map new file mode 100644 index 00000000000..e6f4912e6e1 --- /dev/null +++ b/reportbuilder/amd/build/local/repository/filters.min.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["../../../src/local/repository/filters.js"],"names":["reset","reportId","Ajax","call","methodname","args","reportid"],"mappings":"yKAwBA,uDAQO,GAAMA,CAAAA,CAAK,CAAG,SAAAC,CAAQ,CAAI,CAM7B,MAAOC,WAAKC,IAAL,CAAU,CALD,CACZC,UAAU,CAAE,kCADA,CAEZC,IAAI,CAAE,CAACC,QAAQ,CAAEL,CAAX,CAFM,CAKC,CAAV,EAAqB,CAArB,CACV,CAPM,C","sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/**\n * Module to handle filter AJAX requests\n *\n * @module core_reportbuilder/local/repository/filters\n * @package core_reportbuilder\n * @copyright 2021 Paul Holden \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\nimport Ajax from 'core/ajax';\n\n/**\n * Reset all filters for given report\n *\n * @param {Number} reportId\n * @return {Promise}\n */\nexport const reset = reportId => {\n const request = {\n methodname: 'core_reportbuilder_filters_reset',\n args: {reportid: reportId}\n };\n\n return Ajax.call([request])[0];\n};\n"],"file":"filters.min.js"} \ No newline at end of file diff --git a/reportbuilder/amd/src/filters.js b/reportbuilder/amd/src/filters.js new file mode 100644 index 00000000000..5d7696b87e0 --- /dev/null +++ b/reportbuilder/amd/src/filters.js @@ -0,0 +1,76 @@ +// This file is part of Moodle - http://moodle.org/ +// +// Moodle is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Moodle is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Moodle. If not, see . + +/** + * Report builder filter management + * + * @module core_reportbuilder/filters + * @package core_reportbuilder + * @copyright 2021 Paul Holden + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +import {dispatchEvent} from 'core/event_dispatcher'; +import Notification from 'core/notification'; +import Pending from 'core/pending'; +import {get_string as getString} from 'core/str'; +import {add as addToast} from 'core/toast'; +import DynamicForm from 'core_form/dynamicform'; +import * as reportEvents from 'core_reportbuilder/local/events'; +import * as reportSelectors from 'core_reportbuilder/local/selectors'; +import {reset as resetFilters} from 'core_reportbuilder/local/repository/filters'; + +/** + * Initialise module + * + * @param {Number} reportId + */ +export const init = reportId => { + const reportElement = document.querySelector(reportSelectors.forSystemReport(reportId)); + const filterFormContainer = reportElement.querySelector(reportSelectors.regions.filtersForm); + const filterForm = new DynamicForm(filterFormContainer, '\\core_reportbuilder\\form\\filter'); + + // Submit report filters. + filterForm.addEventListener(filterForm.events.FORM_SUBMITTED, event => { + event.preventDefault(); + + // After the form has been submitted, we should trigger report table reload. + dispatchEvent(reportEvents.tableReload, {}, reportElement); + + getString('filtersapplied', 'core_reportbuilder') + .then(addToast) + .catch(Notification.exception); + }); + + // Reset report filters. + filterForm.addEventListener(filterForm.events.NOSUBMIT_BUTTON_PRESSED, event => { + event.preventDefault(); + + const pendingPromise = new Pending('core_reportbuilder/filters:reset'); + + resetFilters(reportId) + .then(() => getString('filtersreset', 'core_reportbuilder')) + .then(addToast) + .then(() => { + pendingPromise.resolve(); + window.location.reload(); + return; + }) + .catch(Notification.exception); + }); + + // Modify "region-main" overflow for big filter forms. + document.querySelector('#region-main').style.overflowX = "visible"; +}; diff --git a/reportbuilder/amd/src/local/repository/filters.js b/reportbuilder/amd/src/local/repository/filters.js new file mode 100644 index 00000000000..97e033bef9d --- /dev/null +++ b/reportbuilder/amd/src/local/repository/filters.js @@ -0,0 +1,40 @@ +// This file is part of Moodle - http://moodle.org/ +// +// Moodle is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Moodle is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Moodle. If not, see . + +/** + * Module to handle filter AJAX requests + * + * @module core_reportbuilder/local/repository/filters + * @package core_reportbuilder + * @copyright 2021 Paul Holden + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +import Ajax from 'core/ajax'; + +/** + * Reset all filters for given report + * + * @param {Number} reportId + * @return {Promise} + */ +export const reset = reportId => { + const request = { + methodname: 'core_reportbuilder_filters_reset', + args: {reportid: reportId} + }; + + return Ajax.call([request])[0]; +}; diff --git a/reportbuilder/classes/form/filter.php b/reportbuilder/classes/form/filter.php index 1f977dbd74f..54f43fb7b78 100644 --- a/reportbuilder/classes/form/filter.php +++ b/reportbuilder/classes/form/filter.php @@ -118,7 +118,12 @@ class filter extends dynamic_form { // Allow each filter instance to add itself to this form, wrapping each inside custom header/footer template. foreach ($this->get_system_report()->get_filter_instances() as $filterinstance) { + $mform->addElement('html', $OUTPUT->render_from_template('core_reportbuilder/local/filters/header', [ + 'name' => $filterinstance->get_header(), + ])); + $filterinstance->setup_form($mform); + $mform->addElement('html', $OUTPUT->render_from_template('core_reportbuilder/local/filters/footer', [])); } $this->set_display_vertical(); diff --git a/reportbuilder/templates/local/filters/area.mustache b/reportbuilder/templates/local/filters/area.mustache new file mode 100644 index 00000000000..29b3a559968 --- /dev/null +++ b/reportbuilder/templates/local/filters/area.mustache @@ -0,0 +1,53 @@ +{{! + This file is part of Moodle - http://moodle.org/ + + Moodle is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Moodle is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Moodle. If not, see . +}} +{{! + @template core_reportbuilder/filters/area + + Template for system report filters area + + Example context (json): + { + "id": 3, + "filtersform": "form" + } +}} + + + +{{#js}} + require(['core_reportbuilder/filters'], function(filters) { + filters.init('{{id}}'); + }); +{{/js}} diff --git a/reportbuilder/templates/local/filters/footer.mustache b/reportbuilder/templates/local/filters/footer.mustache new file mode 100644 index 00000000000..cf3b3f5e870 --- /dev/null +++ b/reportbuilder/templates/local/filters/footer.mustache @@ -0,0 +1,26 @@ +{{! + This file is part of Moodle - http://moodle.org/ + + Moodle is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Moodle is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Moodle. If not, see . +}} +{{! + @template core_reportbuilder/local/filters/footer + + Template for the footer of a filter instance within the filter form + + Example context (json): + { + } +}} + \ No newline at end of file diff --git a/reportbuilder/templates/local/filters/header.mustache b/reportbuilder/templates/local/filters/header.mustache new file mode 100644 index 00000000000..031e009e09f --- /dev/null +++ b/reportbuilder/templates/local/filters/header.mustache @@ -0,0 +1,32 @@ +{{! + This file is part of Moodle - http://moodle.org/ + + Moodle is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Moodle is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Moodle. If not, see . +}} +{{! + @template core_reportbuilder/local/filters/header + + Template for the header of a filter instance within the filter form + + Example context (json): + { + "name": "Date modified" + } +}} +
+
+
+ {{name}} +
+
\ No newline at end of file diff --git a/reportbuilder/templates/system_report.mustache b/reportbuilder/templates/system_report.mustache index c89841e45a3..5cc996fe2a1 100644 --- a/reportbuilder/templates/system_report.mustache +++ b/reportbuilder/templates/system_report.mustache @@ -34,6 +34,9 @@ data-source="{{source}}" data-parameter="{{parameters}}">
+ {{#filterspresent}} + {{>core_reportbuilder/local/filters/area}} + {{/filterspresent}} {{{table}}}
diff --git a/reportbuilder/tests/behat/behat_reportbuilder.php b/reportbuilder/tests/behat/behat_reportbuilder.php new file mode 100644 index 00000000000..0ea4a6d2b53 --- /dev/null +++ b/reportbuilder/tests/behat/behat_reportbuilder.php @@ -0,0 +1,43 @@ +. + +declare(strict_types=1); + +// NOTE: no MOODLE_INTERNAL test here, this file may be required by behat before including /config.php. +require_once(__DIR__ . '/../../../lib/behat/behat_base.php'); + +/** + * Behat step definitions for Reportbuilder + * + * @package core_reportbuilder + * @copyright 2021 Paul Holden + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class behat_reportbuilder extends behat_base { + + /** + * Return the list of partial named selectors + * + * @return behat_component_named_selector[] + */ + public static function get_partial_named_selectors(): array { + return [ + new behat_component_named_selector('Filter', [ + ".//*[@data-region='filters-form']//*[@data-filter-for=%locator%]", + ]), + ]; + } +} From 7edcf3615d7aa80478aaf76a8bff4d892f2a3985 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikel=20Mart=C3=ADn?= Date: Tue, 20 Apr 2021 12:00:53 +0200 Subject: [PATCH 9/9] MDL-70794 theme_boost: SCSS for Report builder filters dropdown. --- theme/boost/scss/moodle.scss | 1 + theme/boost/scss/moodle/reportbuilder.scss | 46 ++++++++++++++++++++++ theme/boost/style/moodle.css | 32 +++++++++++++++ theme/classic/style/moodle.css | 32 +++++++++++++++ 4 files changed, 111 insertions(+) create mode 100644 theme/boost/scss/moodle/reportbuilder.scss diff --git a/theme/boost/scss/moodle.scss b/theme/boost/scss/moodle.scss index 5066ae560bd..9eb49651a86 100644 --- a/theme/boost/scss/moodle.scss +++ b/theme/boost/scss/moodle.scss @@ -43,3 +43,4 @@ $breadcrumb-divider-rtl: "◀" !default; @import "moodle/atto"; @import "moodle/toasts"; @import "moodle/navbar"; +@import "moodle/reportbuilder"; diff --git a/theme/boost/scss/moodle/reportbuilder.scss b/theme/boost/scss/moodle/reportbuilder.scss new file mode 100644 index 00000000000..e82df1e72da --- /dev/null +++ b/theme/boost/scss/moodle/reportbuilder.scss @@ -0,0 +1,46 @@ +/* Rportbuilder */ +.reportbuilder-table-wrapper { + .filters-dropdown { + width: 27rem; + padding: 0; + @include media-breakpoint-down(sm) { + width: 100%; + } + } + .reportbuilder-filters-wrapper { + .mform { + &.full-width-labels { + .fitem.row { + > .col-md-3, + > .col-md-9 { + flex: 0 0 100%; + max-width: 100%; + } + .fdate_selector { + flex-wrap: wrap; + } + } + } + .form-group { + margin-bottom: 0; + } + } + .filter { + .filter-header { + font-size: $h5-font-size; + .filter-name { + font-size: 1rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + padding-right: 1rem; + } + .filter-name:hover { + white-space: normal; + text-overflow: clip; + word-break: break-all; + } + } + } + } +} diff --git a/theme/boost/style/moodle.css b/theme/boost/style/moodle.css index 2bcfc669f2c..4866cf964f2 100644 --- a/theme/boost/style/moodle.css +++ b/theme/boost/style/moodle.css @@ -20017,6 +20017,38 @@ div.editor_atto_toolbar button .icon { #page { margin-top: 50px; } +/* Rportbuilder */ +.reportbuilder-table-wrapper .filters-dropdown { + width: 27rem; + padding: 0; } + @media (max-width: 767.98px) { + .reportbuilder-table-wrapper .filters-dropdown { + width: 100%; } } + +.reportbuilder-table-wrapper .reportbuilder-filters-wrapper .mform.full-width-labels .fitem.row > .col-md-3, +.reportbuilder-table-wrapper .reportbuilder-filters-wrapper .mform.full-width-labels .fitem.row > .col-md-9 { + flex: 0 0 100%; + max-width: 100%; } + +.reportbuilder-table-wrapper .reportbuilder-filters-wrapper .mform.full-width-labels .fitem.row .fdate_selector { + flex-wrap: wrap; } + +.reportbuilder-table-wrapper .reportbuilder-filters-wrapper .mform .form-group { + margin-bottom: 0; } + +.reportbuilder-table-wrapper .reportbuilder-filters-wrapper .filter .filter-header { + font-size: 1.171875rem; } + .reportbuilder-table-wrapper .reportbuilder-filters-wrapper .filter .filter-header .filter-name { + font-size: 1rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + padding-right: 1rem; } + .reportbuilder-table-wrapper .reportbuilder-filters-wrapper .filter .filter-header .filter-name:hover { + white-space: normal; + text-overflow: clip; + word-break: break-all; } + body { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; } diff --git a/theme/classic/style/moodle.css b/theme/classic/style/moodle.css index 60c4f73915d..54acfb4b797 100644 --- a/theme/classic/style/moodle.css +++ b/theme/classic/style/moodle.css @@ -20208,6 +20208,38 @@ div.editor_atto_toolbar button .icon { #page { margin-top: 50px; } +/* Rportbuilder */ +.reportbuilder-table-wrapper .filters-dropdown { + width: 27rem; + padding: 0; } + @media (max-width: 767.98px) { + .reportbuilder-table-wrapper .filters-dropdown { + width: 100%; } } + +.reportbuilder-table-wrapper .reportbuilder-filters-wrapper .mform.full-width-labels .fitem.row > .col-md-3, +.reportbuilder-table-wrapper .reportbuilder-filters-wrapper .mform.full-width-labels .fitem.row > .col-md-9 { + flex: 0 0 100%; + max-width: 100%; } + +.reportbuilder-table-wrapper .reportbuilder-filters-wrapper .mform.full-width-labels .fitem.row .fdate_selector { + flex-wrap: wrap; } + +.reportbuilder-table-wrapper .reportbuilder-filters-wrapper .mform .form-group { + margin-bottom: 0; } + +.reportbuilder-table-wrapper .reportbuilder-filters-wrapper .filter .filter-header { + font-size: 1.171875rem; } + .reportbuilder-table-wrapper .reportbuilder-filters-wrapper .filter .filter-header .filter-name { + font-size: 1rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + padding-right: 1rem; } + .reportbuilder-table-wrapper .reportbuilder-filters-wrapper .filter .filter-header .filter-name:hover { + white-space: normal; + text-overflow: clip; + word-break: break-all; } + body { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; }