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'); + } +}