From a82b83c2fb66ddc9146ae83da5f23fea53ba3a0d Mon Sep 17 00:00:00 2001 From: Paul Holden Date: Wed, 6 Jul 2022 16:44:04 +0100 Subject: [PATCH] MDL-75166 blog: implement blogs datasource for custom reporting. Create entity definition containing report elements for blog posts. Add new report source joining the entity to existing user, course and tag entities to provide data for the reportbuilder editor. --- .../reportbuilder/datasource/blogs.php | 125 +++++++ .../reportbuilder/local/entities/blog.php | 321 ++++++++++++++++++ .../reportbuilder/datasource/blogs_test.php | 266 +++++++++++++++ 3 files changed, 712 insertions(+) create mode 100644 blog/classes/reportbuilder/datasource/blogs.php create mode 100644 blog/classes/reportbuilder/local/entities/blog.php create mode 100644 blog/tests/reportbuilder/datasource/blogs_test.php diff --git a/blog/classes/reportbuilder/datasource/blogs.php b/blog/classes/reportbuilder/datasource/blogs.php new file mode 100644 index 00000000000..bbfc88071c5 --- /dev/null +++ b/blog/classes/reportbuilder/datasource/blogs.php @@ -0,0 +1,125 @@ +. + +declare(strict_types=1); + +namespace core_blog\reportbuilder\datasource; + +use lang_string; +use core_reportbuilder\datasource; +use core_reportbuilder\local\entities\{course, user}; +use core_blog\reportbuilder\local\entities\blog; +use core_tag\reportbuilder\local\entities\tag; + +/** + * Blogs datasource + * + * @package core_blog + * @copyright 2022 Paul Holden + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class blogs extends datasource { + + /** + * Return user friendly name of the report source + * + * @return string + */ + public static function get_name(): string { + return get_string('blogs', 'core_blog'); + } + + /** + * Initialise report + */ + protected function initialise(): void { + $blogentity = new blog(); + + $postalias = $blogentity->get_table_alias('post'); + $this->set_main_table('post', $postalias); + $this->add_base_condition_simple("{$postalias}.module", 'blog'); + + $this->add_entity($blogentity); + + // Join the tag entity. + $tagentity = (new tag()) + ->set_entity_title(new lang_string('blogtags', 'core_blog')) + ->set_table_alias('tag', $blogentity->get_table_alias('tag')); + $this->add_entity($tagentity + ->add_joins($blogentity->get_tag_joins())); + + // Join the user entity to represent the blog author. + $userentity = new user(); + $useralias = $userentity->get_table_alias('user'); + $this->add_entity($userentity + ->add_join("LEFT JOIN {user} {$useralias} ON {$useralias}.id = {$postalias}.userid")); + + // Join the course entity for course blogs. + $courseentity = new course(); + $coursealias = $courseentity->get_table_alias('course'); + $this->add_entity($courseentity + ->add_join("LEFT JOIN {course} {$coursealias} ON {$coursealias}.id = {$postalias}.courseid")); + + // Add report elements from each of the entities we added to the report. + $this->add_all_from_entity($blogentity->get_entity_name()); + + // Add specific tag entity elements. + $this->add_columns_from_entity($tagentity->get_entity_name(), ['name', 'namewithlink']); + $this->add_filter($tagentity->get_filter('name')); + $this->add_condition($tagentity->get_condition('name')); + + $this->add_all_from_entity($userentity->get_entity_name()); + $this->add_all_from_entity($courseentity->get_entity_name()); + } + + /** + * Return the columns that will be added to the report upon creation + * + * @return string[] + */ + public function get_default_columns(): array { + return [ + 'user:fullname', + 'course:fullname', + 'blog:title', + 'blog:timecreated', + ]; + } + + /** + * Return the filters that will be added to the report upon creation + * + * @return string[] + */ + public function get_default_filters(): array { + return [ + 'user:fullname', + 'blog:title', + 'blog:timecreated', + ]; + } + + /** + * Return the conditions that will be added to the report upon creation + * + * @return string[] + */ + public function get_default_conditions(): array { + return [ + 'blog:publishstate', + ]; + } +} diff --git a/blog/classes/reportbuilder/local/entities/blog.php b/blog/classes/reportbuilder/local/entities/blog.php new file mode 100644 index 00000000000..7a429b7ca02 --- /dev/null +++ b/blog/classes/reportbuilder/local/entities/blog.php @@ -0,0 +1,321 @@ +. + +declare(strict_types=1); + +namespace core_blog\reportbuilder\local\entities; + +use blog_entry_attachment; +use context_system; +use lang_string; +use stdClass; +use core_reportbuilder\local\entities\base; +use core_reportbuilder\local\filters\{boolean_select, date, select, text}; +use core_reportbuilder\local\helpers\format; +use core_reportbuilder\local\report\{column, filter}; + +/** + * Blog entity + * + * @package core_blog + * @copyright 2022 Paul Holden + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class blog extends base { + + /** + * Database tables that this entity uses and their default aliases + * + * @return array + */ + protected function get_default_table_aliases(): array { + return [ + 'post' => 'bp', + 'tag_instance' => 'bti', + 'tag' => 'bt', + ]; + } + + /** + * The default title for this entity + * + * @return lang_string + */ + protected function get_default_entity_title(): lang_string { + return new lang_string('blog', 'core_blog'); + } + + /** + * Initialise the entity + * + * @return base + */ + public function initialise(): base { + $columns = $this->get_all_columns(); + foreach ($columns as $column) { + $this->add_column($column); + } + + // All the filters defined by the entity can also be used as conditions. + $filters = $this->get_all_filters(); + foreach ($filters as $filter) { + $this + ->add_filter($filter) + ->add_condition($filter); + } + + return $this; + } + + /** + * Returns list of all available columns + * + * @return column[] + */ + protected function get_all_columns(): array { + global $DB; + + $postalias = $this->get_table_alias('post'); + + // Title. + $columns[] = (new column( + 'title', + new lang_string('entrytitle', 'core_blog'), + $this->get_entity_name() + )) + ->add_joins($this->get_joins()) + ->set_type(column::TYPE_TEXT) + ->add_fields("{$postalias}.subject") + ->set_is_sortable(true); + + // Body. + $summaryfieldsql = "{$postalias}.summary"; + if ($DB->get_dbfamily() === 'oracle') { + $summaryfieldsql = $DB->sql_order_by_text($summaryfieldsql, 1024); + } + + $columns[] = (new column( + 'body', + new lang_string('entrybody', 'core_blog'), + $this->get_entity_name() + )) + ->add_joins($this->get_joins()) + ->set_type(column::TYPE_LONGTEXT) + ->add_field($summaryfieldsql, 'summary') + ->add_fields("{$postalias}.summaryformat, {$postalias}.id") + ->add_callback(static function(?string $summary, stdClass $blog): string { + global $CFG; + require_once("{$CFG->libdir}/filelib.php"); + + if ($summary === null) { + return ''; + } + + // All blog files are stored in system context. + $context = context_system::instance(); + $summary = file_rewrite_pluginfile_urls($summary, 'pluginfile.php', $context->id, 'blog', 'post', $blog->id); + + return format_text($summary, $blog->summaryformat, ['context' => $context->id]); + }); + + // Attachment. + $columns[] = (new column( + 'attachment', + new lang_string('attachment', 'core_repository'), + $this->get_entity_name() + )) + ->add_joins($this->get_joins()) + ->set_type(column::TYPE_BOOLEAN) + ->add_fields("{$postalias}.attachment, {$postalias}.id") + ->add_callback(static function(bool $attachment, stdClass $post): string { + global $CFG, $PAGE; + require_once("{$CFG->dirroot}/blog/locallib.php"); + + if (!$attachment) { + return ''; + } + + $renderer = $PAGE->get_renderer('core_blog'); + $attachments = ''; + + // Loop over attached files, use blog renderer to generate appropriate content. + $files = get_file_storage()->get_area_files(context_system::instance()->id, 'blog', 'attachment', $post->id, + 'filename', false); + foreach ($files as $file) { + $attachments .= $renderer->render(new blog_entry_attachment($file, $post->id)); + } + + return $attachments; + }) + ->set_disabled_aggregation_all(); + + // Publish state. + $columns[] = (new column( + 'publishstate', + new lang_string('publishto', 'core_blog'), + $this->get_entity_name() + )) + ->add_joins($this->get_joins()) + ->set_type(column::TYPE_TEXT) + ->add_fields("{$postalias}.publishstate") + ->set_is_sortable(true) + ->add_callback(static function(string $publishstate): string { + $states = [ + 'draft' => new lang_string('publishtonoone', 'core_blog'), + 'site' => new lang_string('publishtosite', 'core_blog'), + 'public' => new lang_string('publishtoworld', 'core_blog'), + ]; + + return (string) ($states[$publishstate] ?? $publishstate); + }); + + // Time created. + $columns[] = (new column( + 'timecreated', + new lang_string('timecreated', 'core_reportbuilder'), + $this->get_entity_name() + )) + ->add_joins($this->get_joins()) + ->set_type(column::TYPE_TIMESTAMP) + ->add_fields("{$postalias}.created") + ->set_is_sortable(true) + ->add_callback([format::class, 'userdate']); + + // Time modified. + $columns[] = (new column( + 'timemodified', + new lang_string('timemodified', 'core_reportbuilder'), + $this->get_entity_name() + )) + ->add_joins($this->get_joins()) + ->set_type(column::TYPE_TIMESTAMP) + ->add_fields("{$postalias}.lastmodified") + ->set_is_sortable(true) + ->add_callback([format::class, 'userdate']); + + return $columns; + } + + /** + * Return list of all available filters + * + * @return filter[] + */ + protected function get_all_filters(): array { + global $DB; + + $postalias = $this->get_table_alias('post'); + + // Title. + $filters[] = (new filter( + text::class, + 'title', + new lang_string('entrytitle', 'core_blog'), + $this->get_entity_name(), + "{$postalias}.subject" + )) + ->add_joins($this->get_joins()); + + // Body. + $filters[] = (new filter( + text::class, + 'body', + new lang_string('entrybody', 'core_blog'), + $this->get_entity_name(), + $DB->sql_cast_to_char("{$postalias}.summary") + )) + ->add_joins($this->get_joins()); + + // Attachment. + $filters[] = (new filter( + boolean_select::class, + 'attachment', + new lang_string('attachment', 'core_repository'), + $this->get_entity_name(), + $DB->sql_cast_char2int("{$postalias}.attachment") + )) + ->add_joins($this->get_joins()); + + // Publish state. + $filters[] = (new filter( + select::class, + 'publishstate', + new lang_string('publishto', 'core_blog'), + $this->get_entity_name(), + "{$postalias}.publishstate" + )) + ->add_joins($this->get_joins()) + ->set_options([ + 'draft' => new lang_string('publishtonoone', 'core_blog'), + 'site' => new lang_string('publishtosite', 'core_blog'), + 'public' => new lang_string('publishtoworld', 'core_blog'), + ]); + + // Time created. + $filters[] = (new filter( + date::class, + 'timecreated', + new lang_string('timecreated', 'core_reportbuilder'), + $this->get_entity_name(), + "{$postalias}.created" + )) + ->add_joins($this->get_joins()) + ->set_limited_operators([ + date::DATE_ANY, + date::DATE_CURRENT, + date::DATE_LAST, + date::DATE_RANGE, + ]); + + // Time modified. + $filters[] = (new filter( + date::class, + 'timemodified', + new lang_string('timemodified', 'core_reportbuilder'), + $this->get_entity_name(), + "{$postalias}.lastmodified" + )) + ->add_joins($this->get_joins()) + ->set_limited_operators([ + date::DATE_ANY, + date::DATE_CURRENT, + date::DATE_LAST, + date::DATE_RANGE, + ]); + + return $filters; + } + + /** + * Return joins necessary for retrieving tags + * + * @return string[] + */ + public function get_tag_joins(): array { + $postalias = $this->get_table_alias('post'); + $taginstancealias = $this->get_table_alias('tag_instance'); + $tagalias = $this->get_table_alias('tag'); + + return [ + "LEFT JOIN {tag_instance} {$taginstancealias} + ON {$taginstancealias}.component = 'core' + AND {$taginstancealias}.itemtype = 'post' + AND {$taginstancealias}.itemid = {$postalias}.id", + "LEFT JOIN {tag} {$tagalias} + ON {$tagalias}.id = {$taginstancealias}.tagid", + ]; + } +} diff --git a/blog/tests/reportbuilder/datasource/blogs_test.php b/blog/tests/reportbuilder/datasource/blogs_test.php new file mode 100644 index 00000000000..b6b42425901 --- /dev/null +++ b/blog/tests/reportbuilder/datasource/blogs_test.php @@ -0,0 +1,266 @@ +. + +declare(strict_types=1); + +namespace core_blog\reportbuilder\datasource; + +use context_system; +use core_blog_generator; +use core_collator; +use core_reportbuilder_generator; +use core_reportbuilder_testcase; +use core_reportbuilder\local\filters\{boolean_select, date, select, text}; + +defined('MOODLE_INTERNAL') || die(); + +global $CFG; +require_once("{$CFG->dirroot}/reportbuilder/tests/helpers.php"); + +/** + * Unit tests for blogs datasource + * + * @package core_blog + * @covers \core_blog\reportbuilder\datasource\blogs + * @copyright 2022 Paul Holden + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class blogs_test extends core_reportbuilder_testcase { + + /** + * Test default datasource + */ + public function test_datasource_default(): void { + $this->resetAfterTest(); + + /** @var core_blog_generator $blogsgenerator */ + $blogsgenerator = $this->getDataGenerator()->get_plugin_generator('core_blog'); + + $course = $this->getDataGenerator()->create_course(); + $usercourseblog = $this->getDataGenerator()->create_and_enrol($course); + $courseblog = $blogsgenerator->create_entry(['publishstate' => 'site', 'userid' => $usercourseblog->id, + 'subject' => 'Course', 'summary' => 'Course summary', 'courseid' => $course->id]); + + $userpersonalblog = $this->getDataGenerator()->create_user(); + $personalblog = $blogsgenerator->create_entry(['publishstate' => 'draft', 'userid' => $userpersonalblog->id, + 'subject' => 'Personal', 'summary' => 'Personal summary']); + + $usersiteblog = $this->getDataGenerator()->create_user(); + $siteblog = $blogsgenerator->create_entry(['publishstate' => 'public', 'userid' => $usersiteblog->id, + 'subject' => 'Site', 'summary' => 'Site summary']); + + /** @var core_reportbuilder_generator $generator */ + $generator = $this->getDataGenerator()->get_plugin_generator('core_reportbuilder'); + $report = $generator->create_report(['name' => 'Blogs', 'source' => blogs::class, 'default' => 1]); + + $content = $this->get_custom_report_content($report->get('id')); + $this->assertCount(3, $content); + + // Consistent order (course, personal, site), just in case. + core_collator::asort_array_of_arrays_by_key($content, 'c2_subject'); + $content = array_values($content); + + // Default columns are user, course, title, timecreated. + $this->assertEquals([ + [fullname($usercourseblog), $course->fullname, $courseblog->subject, userdate($courseblog->created)], + [fullname($userpersonalblog), '', $personalblog->subject, userdate($personalblog->created)], + [fullname($usersiteblog), '', $siteblog->subject, userdate($siteblog->created)], + ], array_map('array_values', $content)); + } + + /** + * Test datasource columns that aren't added by default + */ + public function test_datasource_non_default_columns(): void { + global $DB; + + $this->resetAfterTest(); + + $user = $this->getDataGenerator()->create_user(); + + /** @var core_blog_generator $blogsgenerator */ + $blogsgenerator = $this->getDataGenerator()->get_plugin_generator('core_blog'); + $blog = $blogsgenerator->create_entry(['publishstate' => 'draft', 'userid' => $user->id, 'subject' => 'My blog', + 'summary' => 'Horses', 'tags' => ['horse']]); + + // Add an attachment. + $blog->attachment = 1; + get_file_storage()->create_file_from_string([ + 'contextid' => context_system::instance()->id, + 'component' => 'blog', + 'filearea' => 'attachment', + 'itemid' => $blog->id, + 'filepath' => '/', + 'filename' => 'hello.txt', + ], 'hello'); + + // Manually update the created/modified date of the blog. + $blog->created = 1654038000; + $blog->lastmodified = $blog->created + HOURSECS; + $DB->update_record('post', $blog); + + /** @var core_reportbuilder_generator $generator */ + $generator = $this->getDataGenerator()->get_plugin_generator('core_reportbuilder'); + $report = $generator->create_report(['name' => 'Blogs', 'source' => blogs::class, 'default' => 0]); + + $generator->create_column(['reportid' => $report->get('id'), 'uniqueidentifier' => 'blog:body']); + $generator->create_column(['reportid' => $report->get('id'), 'uniqueidentifier' => 'blog:attachment']); + $generator->create_column(['reportid' => $report->get('id'), 'uniqueidentifier' => 'blog:publishstate']); + $generator->create_column(['reportid' => $report->get('id'), 'uniqueidentifier' => 'blog:timemodified']); + + // Tag entity (course/user presence already checked by default columns). + $generator->create_column(['reportid' => $report->get('id'), 'uniqueidentifier' => 'tag:name']); + + $content = $this->get_custom_report_content($report->get('id')); + $this->assertCount(1, $content); + + [$body, $attachment, $publishstate, $timemodified, $tags] = array_values($content[0]); + $this->assertStringContainsString('Horses', $body); + $this->assertStringContainsString('hello.txt', $attachment); + $this->assertEquals('Yourself (draft)', $publishstate); + $this->assertEquals(userdate($blog->lastmodified), $timemodified); + $this->assertEquals('horse', $tags); + } + + /** + * Data provider for {@see test_datasource_filters} + * + * @return array[] + */ + public function datasource_filters_provider(): array { + return [ + 'Filter title' => ['subject', 'Cool', 'blog:title', [ + 'blog:title_operator' => text::CONTAINS, + 'blog:title_value' => 'Cool', + ], true], + 'Filter title (no match)' => ['subject', 'Cool', 'blog:title', [ + 'blog:title_operator' => text::CONTAINS, + 'blog:title_value' => 'Beans', + ], false], + 'Filter body' => ['summary', 'Awesome', 'blog:body', [ + 'blog:body_operator' => select::EQUAL_TO, + 'blog:body_value' => 'Awesome', + ], true], + 'Filter body (no match)' => ['summary', 'Awesome', 'blog:body', [ + 'blog:body_operator' => select::EQUAL_TO, + 'blog:body_value' => 'Beans', + ], false], + 'Filter attachment' => ['attachment', 1, 'blog:attachment', [ + 'blog:attachment_operator' => boolean_select::CHECKED, + ], true], + 'Filter attachment (no match)' => ['attachment', 1, 'blog:attachment', [ + 'blog:attachment_operator' => boolean_select::NOT_CHECKED, + ], false], + 'Filter publish state' => ['publishstate', 'site', 'blog:publishstate', [ + 'blog:publishstate_operator' => select::EQUAL_TO, + 'blog:publishstate_value' => 'site', + ], true], + 'Filter publish state (no match)' => ['publishstate', 'site', 'blog:publishstate', [ + 'blog:publishstate_operator' => select::EQUAL_TO, + 'blog:publishstate_value' => 'draft', + ], false], + 'Filter time created' => ['created', 1654038000, 'blog:timecreated', [ + 'blog:timecreated_operator' => date::DATE_RANGE, + 'blog:timecreated_from' => 1622502000, + ], true], + 'Filter time created (no match)' => ['created', 1654038000, 'blog:timecreated', [ + 'blog:timecreated_operator' => date::DATE_RANGE, + 'blog:timecreated_to' => 1622502000, + ], false], + 'Filter time modified' => ['lastmodified', 1654038000, 'blog:timemodified', [ + 'blog:timemodified_operator' => date::DATE_RANGE, + 'blog:timemodified_from' => 1622502000, + ], true], + 'Filter time modified (no match)' => ['lastmodified', 1654038000, 'blog:timemodified', [ + 'blog:timemodified_operator' => date::DATE_RANGE, + 'blog:timemodified_to' => 1622502000, + ], false], + ]; + } + + /** + * Test datasource filters + * + * @param string $field + * @param mixed $value + * @param string $filtername + * @param array $filtervalues + * @param bool $expectmatch + * + * @dataProvider datasource_filters_provider + */ + public function test_datasource_filters( + string $field, + $value, + string $filtername, + array $filtervalues, + bool $expectmatch + ): void { + global $DB; + + $this->resetAfterTest(); + + $user = $this->getDataGenerator()->create_user(); + + /** @var core_blog_generator $blogsgenerator */ + $blogsgenerator = $this->getDataGenerator()->get_plugin_generator('core_blog'); + + // Create default blog, then manually override one of it's properties to use for filtering. + $blog = $blogsgenerator->create_entry(['userid' => $user->id, 'subject' => 'My blog', 'summary' => 'Horses']); + $DB->set_field('post', $field, $value, ['id' => $blog->id]); + + /** @var core_reportbuilder_generator $generator */ + $generator = $this->getDataGenerator()->get_plugin_generator('core_reportbuilder'); + + // Create report containing single user column, and given filter. + $report = $generator->create_report(['name' => 'Blogs', 'source' => blogs::class, 'default' => 0]); + $generator->create_column(['reportid' => $report->get('id'), 'uniqueidentifier' => 'user:fullname']); + + // Add filter, set it's values. + $generator->create_filter(['reportid' => $report->get('id'), 'uniqueidentifier' => $filtername]); + $content = $this->get_custom_report_content($report->get('id'), 0, $filtervalues); + + if ($expectmatch) { + $this->assertCount(1, $content); + $this->assertEquals(fullname($user), reset($content[0])); + } else { + $this->assertEmpty($content); + } + } + + /** + * Stress test datasource + * + * In order to execute this test PHPUNIT_LONGTEST should be defined as true in phpunit.xml or directly in config.php + */ + public function test_stress_datasource(): void { + if (!PHPUNIT_LONGTEST) { + $this->markTestSkipped('PHPUNIT_LONGTEST is not defined'); + } + + $this->resetAfterTest(); + + $user = $this->getDataGenerator()->create_user(); + + /** @var core_blog_generator $blogsgenerator */ + $blogsgenerator = $this->getDataGenerator()->get_plugin_generator('core_blog'); + $blogsgenerator->create_entry(['userid' => $user->id, 'subject' => 'My blog', 'summary' => 'Horses']); + + $this->datasource_stress_test_columns(blogs::class); + $this->datasource_stress_test_columns_aggregation(blogs::class); + $this->datasource_stress_test_conditions(blogs::class, 'blog:title'); + } +}