MDL-73842 reportbuilder: add tags elements to course/user entities.

Both entities define columns and filters for related tags (referred
to as "interests" for users).

Create new generic tags filter type to facilitate this, which can be
re-used by any entities for components which support tagging.
This commit is contained in:
Paul Holden
2022-02-11 10:15:59 +00:00
parent ca583bddaf
commit 9ebd801c07
8 changed files with 394 additions and 4 deletions

View File

@@ -20,10 +20,12 @@ namespace core_reportbuilder\local\entities;
use context_course; use context_course;
use context_helper; use context_helper;
use core_tag_tag;
use core_reportbuilder\local\filters\boolean_select; use core_reportbuilder\local\filters\boolean_select;
use core_reportbuilder\local\filters\course_selector; use core_reportbuilder\local\filters\course_selector;
use core_reportbuilder\local\filters\date; use core_reportbuilder\local\filters\date;
use core_reportbuilder\local\filters\select; use core_reportbuilder\local\filters\select;
use core_reportbuilder\local\filters\tags;
use core_reportbuilder\local\filters\text; use core_reportbuilder\local\filters\text;
use core_reportbuilder\local\helpers\custom_fields; use core_reportbuilder\local\helpers\custom_fields;
use core_reportbuilder\local\helpers\format; use core_reportbuilder\local\helpers\format;
@@ -58,6 +60,8 @@ class course extends base {
return [ return [
'course' => 'c', 'course' => 'c',
'context' => 'cctx', 'context' => 'cctx',
'tag_instance' => 'ti',
'tag' => 't',
]; ];
} }
@@ -193,6 +197,26 @@ class course extends base {
return $fieldtype; return $fieldtype;
} }
/**
* Return joins necessary for retrieving tags
*
* @return string[]
*/
private function get_tag_joins(): array {
$course = $this->get_table_alias('course');
$taginstance = $this->get_table_alias('tag_instance');
$tag = $this->get_table_alias('tag');
return [
"LEFT JOIN {tag_instance} {$taginstance}
ON {$taginstance}.component = 'core'
AND {$taginstance}.itemtype = 'course'
AND {$taginstance}.itemid = {$course}.id",
"LEFT JOIN {tag} {$tag}
ON {$tag}.id = {$taginstance}.tagid",
];
}
/** /**
* Returns list of all available columns. * Returns list of all available columns.
* *
@@ -272,6 +296,22 @@ class course extends base {
$columns[] = $column; $columns[] = $column;
} }
// Tags.
$tag = $this->get_table_alias('tag');
$columns[] = (new column(
'tags',
new lang_string('tags'),
$this->get_entity_name()
))
->add_joins($this->get_joins())
->add_joins($this->get_tag_joins())
->set_type(column::TYPE_TEXT)
->add_fields("{$tag}.name, {$tag}.rawname")
->set_is_sortable(true)
->add_callback(static function($value, stdClass $tag): string {
return core_tag_tag::make_display_name($tag);
});
return $columns; return $columns;
} }
@@ -323,6 +363,22 @@ class course extends base {
$filters[] = $filter; $filters[] = $filter;
} }
// Tags.
$tag = $this->get_table_alias('tag');
$filters[] = (new filter(
tags::class,
'tags',
new lang_string('tags'),
$this->get_entity_name(),
"{$tag}.id"
))
->add_joins($this->get_joins())
->add_joins($this->get_tag_joins())
->set_options([
'component' => 'core',
'itemtype' => 'course',
]);
// We add our own custom course selector filter. // We add our own custom course selector filter.
$filters[] = (new filter( $filters[] = (new filter(
course_selector::class, course_selector::class,

View File

@@ -21,6 +21,7 @@ namespace core_reportbuilder\local\entities;
use context_helper; use context_helper;
use context_system; use context_system;
use context_user; use context_user;
use core_tag_tag;
use html_writer; use html_writer;
use lang_string; use lang_string;
use moodle_url; use moodle_url;
@@ -29,6 +30,7 @@ use core_user\fields;
use core_reportbuilder\local\filters\boolean_select; use core_reportbuilder\local\filters\boolean_select;
use core_reportbuilder\local\filters\date; use core_reportbuilder\local\filters\date;
use core_reportbuilder\local\filters\select; use core_reportbuilder\local\filters\select;
use core_reportbuilder\local\filters\tags;
use core_reportbuilder\local\filters\text; use core_reportbuilder\local\filters\text;
use core_reportbuilder\local\filters\user as user_filter; use core_reportbuilder\local\filters\user as user_filter;
use core_reportbuilder\local\helpers\user_profile_fields; use core_reportbuilder\local\helpers\user_profile_fields;
@@ -56,6 +58,8 @@ class user extends base {
return [ return [
'user' => 'u', 'user' => 'u',
'context' => 'uctx', 'context' => 'uctx',
'tag_instance' => 'ti',
'tag' => 't',
]; ];
} }
@@ -105,6 +109,26 @@ class user extends base {
return $userprofilefields; return $userprofilefields;
} }
/**
* Return joins necessary for retrieving tags
*
* @return string[]
*/
private function get_tag_joins(): array {
$user = $this->get_table_alias('user');
$taginstance = $this->get_table_alias('tag_instance');
$tag = $this->get_table_alias('tag');
return [
"LEFT JOIN {tag_instance} {$taginstance}
ON {$taginstance}.component = 'core'
AND {$taginstance}.itemtype = 'user'
AND {$taginstance}.itemid = {$user}.id",
"LEFT JOIN {tag} {$tag}
ON {$tag}.id = {$taginstance}.tagid",
];
}
/** /**
* Returns list of all available columns * Returns list of all available columns
* *
@@ -219,6 +243,22 @@ class user extends base {
return !empty($row->id) ? $OUTPUT->user_picture($row, ['link' => false, 'alttext' => false]) : ''; return !empty($row->id) ? $OUTPUT->user_picture($row, ['link' => false, 'alttext' => false]) : '';
}); });
// Interests (tags).
$tag = $this->get_table_alias('tag');
$columns[] = (new column(
'interests',
new lang_string('interests'),
$this->get_entity_name()
))
->add_joins($this->get_joins())
->add_joins($this->get_tag_joins())
->set_type(column::TYPE_TEXT)
->add_fields("{$tag}.name, {$tag}.rawname")
->set_is_sortable(true)
->add_callback(static function($value, stdClass $tag): string {
return core_tag_tag::make_display_name($tag);
});
// Add all other user fields. // Add all other user fields.
$userfields = $this->get_user_fields(); $userfields = $this->get_user_fields();
foreach ($userfields as $userfield => $userfieldlang) { foreach ($userfields as $userfield => $userfieldlang) {
@@ -460,6 +500,22 @@ class user extends base {
$filters[] = $filter; $filters[] = $filter;
} }
// Interests (tags).
$tag = $this->get_table_alias('tag');
$filters[] = (new filter(
tags::class,
'interests',
new lang_string('interests'),
$this->get_entity_name(),
"{$tag}.id"
))
->add_joins($this->get_joins())
->add_joins($this->get_tag_joins())
->set_options([
'component' => 'core',
'itemtype' => 'user',
]);
// User select filter. // User select filter.
$filters[] = (new filter( $filters[] = (new filter(
user_filter::class, user_filter::class,

View File

@@ -0,0 +1,133 @@
<?php
// 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 <http://www.gnu.org/licenses/>.
declare(strict_types=1);
namespace core_reportbuilder\local\filters;
use coding_exception;
use core_tag_tag;
use lang_string;
use MoodleQuickForm;
use stdClass;
use core_reportbuilder\local\helpers\database;
/**
* Class containing logic for the tags filter
*
* The field SQL should be the field containing the ID of the {tag} table
*
* The following array properties must be passed to the {@see \core_reportbuilder\local\report\filter::set_options} method when
* defining this filter, to define the component/itemtype you are using for tags:
*
* ['component' => 'core', 'itemtype' => 'user']
*
* @package core_reportbuilder
* @copyright 2022 Paul Holden <paulh@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class tags extends base {
/** @var int Any value */
public const ANY_VALUE = 0;
/** @var int Tags are present */
public const NOT_EMPTY = 1;
/** @var int Filter for selected tags */
public const EQUAL_TO = 2;
/**
* Returns an array of comparison operators
*
* @return array
*/
private function get_operators(): array {
$operators = [
self::ANY_VALUE => new lang_string('filterisanyvalue', 'core_reportbuilder'),
self::NOT_EMPTY => new lang_string('filterisnotempty', 'core_reportbuilder'),
self::EQUAL_TO => new lang_string('filterisequalto', 'core_reportbuilder'),
];
return $this->filter->restrict_limited_operators($operators);
}
/**
* Setup form
*
* @param MoodleQuickForm $mform
* @throws coding_exception If component/itemtype options are missing
*/
public function setup_form(MoodleQuickForm $mform): void {
global $DB;
$options = $this->filter->get_options();
if (!array_key_exists('component', $options) || !array_key_exists('itemtype', $options)) {
throw new coding_exception('Missing \'component\' and/or \'itemtype\' in filter options');
}
$operatorlabel = get_string('filterfieldoperator', 'core_reportbuilder', $this->get_header());
$mform->addElement('select', "{$this->name}_operator", $operatorlabel, $this->get_operators())
->setHiddenLabel(true);
$sql = 'SELECT DISTINCT t.id, t.name, t.rawname
FROM {tag} t
JOIN {tag_instance} ti ON ti.tagid = t.id
WHERE ti.component = :component AND ti.itemtype = :itemtype
ORDER BY t.name';
// Transform tag records into appropriate display name, for selection in the autocomplete element.
$tags = array_map(static function(stdClass $record): string {
return core_tag_tag::make_display_name($record);
}, $DB->get_records_sql($sql, ['component' => $options['component'], 'itemtype' => $options['itemtype']]));
$valuelabel = get_string('filterfieldvalue', 'core_reportbuilder', $this->get_header());
$mform->addElement('autocomplete', "{$this->name}_value", $valuelabel, $tags, ['multiple' => true])
->setHiddenLabel(true);
$mform->hideIf("{$this->name}_value", "{$this->name}_operator", 'neq', self::EQUAL_TO);
}
/**
* 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();
$operator = (int) ($values["{$this->name}_operator"] ?? self::ANY_VALUE);
$tags = (array) ($values["{$this->name}_value"] ?? []);
if ($operator === self::NOT_EMPTY) {
$select = "{$fieldsql} IS NOT NULL";
} else if ($operator === self::EQUAL_TO && !empty($tags)) {
[$tagselect, $tagselectparams] = $DB->get_in_or_equal($tags, SQL_PARAMS_NAMED,
database::generate_param_name() . '_');
$select = "{$fieldsql} {$tagselect}";
$params = array_merge($params, $tagselectparams);
} else {
// Invalid/inactive (any value) filter..
return ['', []];
}
return [$select, $params];
}
}

View File

@@ -13,9 +13,9 @@ Feature: Manage custom report conditions
| My report | user:fullname | | My report | user:fullname |
| My report | user:email | | My report | user:email |
And the following "users" exist: And the following "users" exist:
| username | firstname | lastname | email | | username | firstname | lastname | email | interests |
| user01 | User | One | user01@example.com | | user01 | User | One | user01@example.com | lionel, dancing |
| user02 | User | Two | user02@example.com | | user02 | User | Two | user02@example.com | |
Scenario: Add condition to report Scenario: Add condition to report
Given I am on the "My report" "reportbuilder > Editor" page logged in as "admin" Given I am on the "My report" "reportbuilder > Editor" page logged in as "admin"
@@ -33,6 +33,20 @@ Feature: Manage custom report conditions
And I should see "User One" in the "reportbuilder-table" "table" And I should see "User One" in the "reportbuilder-table" "table"
And I should not see "User Two" in the "reportbuilder-table" "table" And I should not see "User Two" in the "reportbuilder-table" "table"
Scenario: Add tags condition to report
Given the following "core_reportbuilder > Condition" exists:
| report | My report |
| uniqueidentifier | user:interests |
And I am on the "My report" "reportbuilder > Editor" page logged in as "admin"
When I click on "Show/hide 'Conditions'" "button"
And I set the following fields in the "Interests" "core_reportbuilder > Condition" to these values:
| Interests operator | Is equal to |
| Interests value | dancing |
And I click on "Apply" "button" in the "[data-region='settings-conditions']" "css_element"
Then I should see "Conditions applied"
And I should see "User One" in the "reportbuilder-table" "table"
And I should not see "User Two" in the "reportbuilder-table" "table"
Scenario: Move condition in report Scenario: Move condition in report
Given the following "core_reportbuilder > Conditions" exist: Given the following "core_reportbuilder > Conditions" exist:
| report | uniqueidentifier | | report | uniqueidentifier |

View File

@@ -27,6 +27,7 @@ use core_reportbuilder\manager;
use core_reportbuilder\testable_system_report_table; use core_reportbuilder\testable_system_report_table;
use core_reportbuilder\local\filters\boolean_select; use core_reportbuilder\local\filters\boolean_select;
use core_reportbuilder\local\filters\date; use core_reportbuilder\local\filters\date;
use core_reportbuilder\local\filters\tags;
use core_reportbuilder\local\filters\text; use core_reportbuilder\local\filters\text;
use core_reportbuilder\local\filters\select; use core_reportbuilder\local\filters\select;
use core_reportbuilder\local\helpers\user_filter_manager; use core_reportbuilder\local\helpers\user_filter_manager;
@@ -76,6 +77,7 @@ class course_test extends advanced_testcase {
'calendartype' => 'Gregorian', 'calendartype' => 'Gregorian',
'theme' => 'afterburner', 'theme' => 'afterburner',
'lang' => 'en', 'lang' => 'en',
'tags' => ['dancing'],
]); ]);
$coursecategory2 = $this->getDataGenerator()->create_category(); $coursecategory2 = $this->getDataGenerator()->create_category();
@@ -154,6 +156,7 @@ class course_test extends advanced_testcase {
$this->assertEquals('Gregorian', $courserow['calendartype']); $this->assertEquals('Gregorian', $courserow['calendartype']);
$this->assertEquals('afterburner', $courserow['theme']); $this->assertEquals('afterburner', $courserow['theme']);
$this->assertEquals(get_string_manager()->get_list_of_translations()['en'], $courserow['lang']); $this->assertEquals(get_string_manager()->get_list_of_translations()['en'], $courserow['lang']);
$this->assertEquals('dancing', $courserow['tags']);
$expected = '<a href="https://www.example.com/moodle/course/view.php?id=' . $course1->id . '">Course 1</a>'; $expected = '<a href="https://www.example.com/moodle/course/view.php?id=' . $course1->id . '">Course 1</a>';
$this->assertEquals($expected, $courserow['coursefullnamewithlink']); $this->assertEquals($expected, $courserow['coursefullnamewithlink']);
$expected = '<a href="https://www.example.com/moodle/course/view.php?id=' . $course1->id . '">C1</a>'; $expected = '<a href="https://www.example.com/moodle/course/view.php?id=' . $course1->id . '">C1</a>';
@@ -171,6 +174,8 @@ class course_test extends advanced_testcase {
* Test filtering report by course fields * Test filtering report by course fields
*/ */
public function test_filters(): void { public function test_filters(): void {
global $DB;
$this->resetAfterTest(); $this->resetAfterTest();
[$coursecategory1] = $this->generate_courses(); [$coursecategory1] = $this->generate_courses();
@@ -243,6 +248,15 @@ class course_test extends advanced_testcase {
$this->assertEquals([ $this->assertEquals([
'Course 1', 'Course 1',
], array_column($tablerows, 'fullname')); ], array_column($tablerows, 'fullname'));
// Filter by tags field.
$tablerows = $this->get_report_table_rows([
'course:tags_operator' => tags::EQUAL_TO,
'course:tags_value' => [
$DB->get_field('tag', 'id', ['name' => 'dancing'], MUST_EXIST),
],
]);
$this->assertEquals(['Course 1'], array_column($tablerows, 'fullname'));
} }
/** /**

View File

@@ -26,6 +26,7 @@ use core_reportbuilder\user_entity_report;
use core_reportbuilder\local\filters\boolean_select; use core_reportbuilder\local\filters\boolean_select;
use core_reportbuilder\local\filters\date; use core_reportbuilder\local\filters\date;
use core_reportbuilder\local\filters\select; use core_reportbuilder\local\filters\select;
use core_reportbuilder\local\filters\tags;
use core_reportbuilder\local\filters\text; use core_reportbuilder\local\filters\text;
use core_reportbuilder\local\helpers\user_filter_manager; use core_reportbuilder\local\helpers\user_filter_manager;
@@ -70,6 +71,7 @@ class user_test extends advanced_testcase {
'suspended' => 1, 'suspended' => 1,
'confirmed' => 0, 'confirmed' => 0,
'country' => 'ES', 'country' => 'ES',
'interests' => ['dancing'],
'profile_field_favcolor' => 'Blue', 'profile_field_favcolor' => 'Blue',
'profile_field_favsuperpower' => 'Time travel', 'profile_field_favsuperpower' => 'Time travel',
]); ]);
@@ -83,6 +85,7 @@ class user_test extends advanced_testcase {
$this->assertEquals('Yes', $userrow['suspended']); $this->assertEquals('Yes', $userrow['suspended']);
$this->assertEquals('No', $userrow['confirmed']); $this->assertEquals('No', $userrow['confirmed']);
$this->assertEquals('Spain', $userrow['country']); $this->assertEquals('Spain', $userrow['country']);
$this->assertEquals('dancing', $userrow['interests']);
$this->assertEquals('Blue', $userrow['profilefield_favcolor']); $this->assertEquals('Blue', $userrow['profilefield_favcolor']);
$this->assertEquals('Time travel', $userrow['profilefield_favsuperpower']); $this->assertEquals('Time travel', $userrow['profilefield_favsuperpower']);
} }
@@ -138,10 +141,12 @@ class user_test extends advanced_testcase {
* Test filtering report by user fields * Test filtering report by user fields
*/ */
public function test_filters(): void { public function test_filters(): void {
global $DB;
$this->resetAfterTest(); $this->resetAfterTest();
$this->getDataGenerator()->create_user(['firstname' => 'Daffy', 'lastname' => 'Duck', 'email' => 'daffy@test.com', $this->getDataGenerator()->create_user(['firstname' => 'Daffy', 'lastname' => 'Duck', 'email' => 'daffy@test.com',
'city' => 'LA', 'lastaccess' => time() - YEARSECS, 'suspended' => 1]); 'city' => 'LA', 'lastaccess' => time() - YEARSECS, 'suspended' => 1, 'interests' => ['dancing']]);
$this->getDataGenerator()->create_user(['firstname' => 'Donald', 'lastname' => 'Duck', 'email' => 'donald@test.com', $this->getDataGenerator()->create_user(['firstname' => 'Donald', 'lastname' => 'Duck', 'email' => 'donald@test.com',
'city' => 'Chicago', 'lastaccess' => time(), 'suspended' => 0]); 'city' => 'Chicago', 'lastaccess' => time(), 'suspended' => 0]);
@@ -225,6 +230,15 @@ class user_test extends advanced_testcase {
$this->assertEquals([ $this->assertEquals([
'Daffy Duck', 'Daffy Duck',
], array_column($tablerows, 'fullname')); ], array_column($tablerows, 'fullname'));
// Filter by interests (tags) field.
$tablerows = $this->get_report_table_rows([
'user:interests_operator' => tags::EQUAL_TO,
'user:interests_value' => [
$DB->get_field('tag', 'id', ['name' => 'dancing'], MUST_EXIST),
],
]);
$this->assertEquals(['Daffy Duck'], array_column($tablerows, 'fullname'));
} }
/** /**

View File

@@ -0,0 +1,102 @@
<?php
// 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 <http://www.gnu.org/licenses/>.
declare(strict_types=1);
namespace core_reportbuilder\local\filters;
use advanced_testcase;
use lang_string;
use core_reportbuilder\local\report\filter;
/**
* Unit tests for tags report filter
*
* @package core_reportbuilder
* @covers \core_reportbuilder\local\filters\base
* @covers \core_reportbuilder\local\filters\tags
* @copyright 2022 Paul Holden <paulh@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class tags_test extends advanced_testcase {
/**
* Data provider for {@see test_get_sql_filter}
*
* @return array[]
*/
public function get_sql_filter_provider(): array {
return [
'Any value' => [tags::ANY_VALUE, null, ['course01', 'course01', 'course02', 'course03']],
'Not empty' => [tags::NOT_EMPTY, null, ['course01', 'course01', 'course02']],
'Equal to unselected' => [tags::EQUAL_TO, null, ['course01', 'course01', 'course02', 'course03']],
'Equal to selected tag' => [tags::EQUAL_TO, 'cat', ['course01']],
];
}
/**
* Test getting filter SQL
*
* @param int $operator
* @param string|null $tagname
* @param array $expectedcoursenames
*
* @dataProvider get_sql_filter_provider
*/
public function test_get_sql_filter(int $operator, ?string $tagname, array $expectedcoursenames): void {
global $DB;
$this->resetAfterTest();
$this->getDataGenerator()->create_course(['fullname' => 'course01', 'tags' => ['cat', 'dog']]);
$this->getDataGenerator()->create_course(['fullname' => 'course02', 'tags' => ['fish']]);
$this->getDataGenerator()->create_course(['fullname' => 'course03']);
$filter = (new filter(
tags::class,
'tags',
new lang_string('tags'),
'testentity',
't.id'
));
// Create instance of our filter, passing ID of the tag if specified.
if ($tagname !== null) {
$tagid = $DB->get_field('tag', 'id', ['name' => $tagname], MUST_EXIST);
$value = [$tagid];
} else {
$value = null;
}
[$select, $params] = tags::create($filter)->get_sql_filter([
$filter->get_unique_identifier() . '_operator' => $operator,
$filter->get_unique_identifier() . '_value' => $value,
]);
$sql = 'SELECT c.fullname
FROM {course} c
LEFT JOIN {tag_instance} ti ON ti.itemid = c.id
LEFT JOIN {tag} t ON t.id = ti.tagid
WHERE c.id != ' . SITEID;
if ($select) {
$sql .= " AND {$select}";
}
$courses = $DB->get_fieldset_sql($sql, $params);
$this->assertEqualsCanonicalizing($expectedcoursenames, $courses);
}
}

View File

@@ -19,3 +19,4 @@ Information provided here is intended especially for developers.
- `[require_]can_create_report` - `[require_]can_create_report`
* New method `get_default_condition_values()` in base datasource class, to be overridden by sources that wish to * New method `get_default_condition_values()` in base datasource class, to be overridden by sources that wish to
define default values for conditions upon report creation. define default values for conditions upon report creation.
* New `tags` filter type for reports that contain entities with support for core_tag API