From 270da6239406323417b0c7c04d4c5a97f6bdf70d Mon Sep 17 00:00:00 2001 From: Paul Holden Date: Tue, 20 Feb 2024 10:26:48 +0000 Subject: [PATCH 01/22] MDL-80865 customfield_textarea: empty check prior to exporting value. Co-authored-by: Sara Bozzini --- .../field/textarea/classes/data_controller.php | 5 ++++- customfield/field/textarea/tests/plugin_test.php | 11 ++++++++--- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/customfield/field/textarea/classes/data_controller.php b/customfield/field/textarea/classes/data_controller.php index b5cb10dbf523a..e78ad4df6ff21 100644 --- a/customfield/field/textarea/classes/data_controller.php +++ b/customfield/field/textarea/classes/data_controller.php @@ -151,7 +151,7 @@ public function instance_form_before_set_data(\stdClass $instance) { /** * Checks if the value is empty, overriding the base method to ensure it's the "text" element of our value being compared * - * @param mixed $value + * @param string|string[] $value * @return bool */ protected function is_empty($value): bool { @@ -201,6 +201,9 @@ public function export_value() { require_once($CFG->libdir . '/filelib.php'); $value = $this->get_value(); + if ($this->is_empty($value)) { + return null; + } if ($dataid = $this->get('id')) { $context = $this->get_context(); diff --git a/customfield/field/textarea/tests/plugin_test.php b/customfield/field/textarea/tests/plugin_test.php index 884e230a9453f..1382eb58fff52 100644 --- a/customfield/field/textarea/tests/plugin_test.php +++ b/customfield/field/textarea/tests/plugin_test.php @@ -25,10 +25,12 @@ * @package customfield_textarea * @copyright 2019 Marina Glancy * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @covers \customfield_textarea\field_controller + * @covers \customfield_textarea\data_controller */ class plugin_test extends \advanced_testcase { - /** @var stdClass[] */ + /** @var \stdClass[] */ private $courses = []; /** @var \core_customfield\category_controller */ private $cfcat; @@ -61,7 +63,7 @@ public function setUp(): void { $this->cfdata[1] = $this->get_generator()->add_instance_data($this->cfields[1], $this->courses[1]->id, ['text' => 'Value1', 'format' => FORMAT_MOODLE]); $this->cfdata[2] = $this->get_generator()->add_instance_data($this->cfields[1], $this->courses[2]->id, - ['text' => 'Value2', 'format' => FORMAT_MOODLE]); + ['text' => '
', 'format' => FORMAT_MOODLE]); $this->setUser($this->getDataGenerator()->create_user()); } @@ -170,7 +172,7 @@ public function test_instance_form_save_clear(): void { $form = new core_customfield_test_instance_form('post', ['handler' => $handler, 'instance' => $this->courses[1]]); $handler->instance_form_save($form->get_data()); - $this->assertEmpty(\core_customfield\data_controller::create($this->cfdata[1]->get('id'))->export_value()); + $this->assertNull(\core_customfield\data_controller::create($this->cfdata[1]->get('id'))->export_value()); } /** @@ -180,6 +182,9 @@ public function test_get_export_value() { $this->assertEquals('Value1', $this->cfdata[1]->get_value()); $this->assertEquals('
Value1
', $this->cfdata[1]->export_value()); + // Field with empty data. + $this->assertNull($this->cfdata[2]->export_value()); + // Field without data but with a default value. $d = \core_customfield\data_controller::create(0, null, $this->cfields[3]); $this->assertEquals('Value3', $d->get_value()); From f8f04b6f6383a5bc00b7036d9fa548a4ed198136 Mon Sep 17 00:00:00 2001 From: Juan Leyva Date: Thu, 22 Feb 2024 17:14:05 +0100 Subject: [PATCH 02/22] MDL-81007 tool_policy: Return permissions per policy --- .../classes/external/get_user_acceptances.php | 8 ++++++++ admin/tool/policy/tests/externallib_test.php | 18 ++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/admin/tool/policy/classes/external/get_user_acceptances.php b/admin/tool/policy/classes/external/get_user_acceptances.php index 227e22f675464..ba3f2541689a3 100644 --- a/admin/tool/policy/classes/external/get_user_acceptances.php +++ b/admin/tool/policy/classes/external/get_user_acceptances.php @@ -118,6 +118,11 @@ public static function execute(int $userid = 0): array { [$policy['acceptance']['note']] = util::format_text($version->acceptance->note, FORMAT_MOODLE, $systemcontext); } } + // Return permission for actions for the current policy and user. + $policy['canaccept'] = api::can_accept_policies([$version->id], $user->id); + $policy['candecline'] = api::can_decline_policies([$version->id], $user->id); + $policy['canrevoke'] = api::can_revoke_policies([$version->id], $user->id); + $policies[] = $policy; } } @@ -157,6 +162,9 @@ public static function execute_returns(): external_single_structure { 'note' => new external_value(PARAM_TEXT, 'The policy note/remarks.', VALUE_OPTIONAL), 'modfullname' => new external_value(PARAM_NOTAGS, 'The fullname who accepted on behalf.', VALUE_OPTIONAL), ], 'Acceptance status for the given user.', VALUE_OPTIONAL), + 'canaccept' => new external_value(PARAM_BOOL, 'Whether the policy can be accepted.'), + 'candecline' => new external_value(PARAM_BOOL, 'Whether the policy can be declined.'), + 'canrevoke' => new external_value(PARAM_BOOL, 'Whether the policy can be revoked.'), ]), 'Policies and acceptance status for the given user.', VALUE_OPTIONAL ), 'warnings' => new external_warnings(), diff --git a/admin/tool/policy/tests/externallib_test.php b/admin/tool/policy/tests/externallib_test.php index 9796b2fae008e..1d782741fad4f 100644 --- a/admin/tool/policy/tests/externallib_test.php +++ b/admin/tool/policy/tests/externallib_test.php @@ -271,9 +271,15 @@ public function test_external_get_user_acceptances() { if ($policy['versionid'] == $this->policy2->get('id')) { $this->assertEquals($this->policy2->get('name'), $policy['name']); $this->assertEquals(0, $policy['optional']); + $this->assertTrue($policy['canaccept']); + $this->assertFalse($policy['candecline']); // Cannot decline or revoke mandatory for myself. + $this->assertFalse($policy['canrevoke']); } else { $this->assertEquals($optionalpolicy->get('name'), $policy['name']); $this->assertEquals(1, $policy['optional']); + $this->assertTrue($policy['canaccept']); + $this->assertTrue($policy['candecline']); // Can decline or revoke optional for myself. + $this->assertTrue($policy['canrevoke']); } $this->assertNotContains('acceptance', $policy); // Nothing accepted yet. } @@ -285,6 +291,18 @@ public function test_external_get_user_acceptances() { $policies = \core_external\external_api::clean_returnvalue( \tool_policy\external\get_user_acceptances::execute_returns(), $policies); $this->assertCount(2, $policies['policies']); + foreach ($policies['policies'] as $policy) { + if ($policy['versionid'] == $this->policy2->get('id')) { + $this->assertTrue($policy['canaccept']); + $this->assertFalse($policy['candecline']); // Cannot decline mandatory in general. + $this->assertTrue($policy['canrevoke']); + } else { + $this->assertTrue($policy['canaccept']); + $this->assertTrue($policy['candecline']); + $this->assertTrue($policy['canrevoke']); + } + $this->assertNotContains('acceptance', $policy); // Nothing accepted yet. + } // Get other user acceptances without permission. $this->expectException(\required_capability_exception::class); From 8dfef80c0e4a188bf34c7c898091895c67b223ad Mon Sep 17 00:00:00 2001 From: Juan Leyva Date: Thu, 22 Feb 2024 17:57:53 +0100 Subject: [PATCH 03/22] MDL-81007 tool_policy: Allow acceptance/decline of non-current policies --- .../external/set_acceptances_status.php | 17 ++++--- admin/tool/policy/tests/externallib_test.php | 47 ++++++++++++++----- 2 files changed, 46 insertions(+), 18 deletions(-) diff --git a/admin/tool/policy/classes/external/set_acceptances_status.php b/admin/tool/policy/classes/external/set_acceptances_status.php index 0bfb6d410b86e..008e19014f7f1 100644 --- a/admin/tool/policy/classes/external/set_acceptances_status.php +++ b/admin/tool/policy/classes/external/set_acceptances_status.php @@ -91,18 +91,21 @@ public static function execute(array $policies, int $userid = 0): array { } // Split acceptances. - $allcurrentpolicies = api::list_current_versions(policy_version::AUDIENCE_LOGGEDIN); $requestedpolicies = $agreepolicies = $declinepolicies = []; foreach ($params['policies'] as $policy) { $requestedpolicies[$policy['versionid']] = $policy['status']; } - foreach ($allcurrentpolicies as $policy) { - if (isset($requestedpolicies[$policy->id])) { - if ($requestedpolicies[$policy->id] === 1) { - $agreepolicies[] = $policy->id; - } else if ($requestedpolicies[$policy->id] === 0) { - $declinepolicies[] = $policy->id; + // Retrieve all policies and their acceptances. + $allpolicies = api::get_policies_with_acceptances($user->id); + foreach ($allpolicies as $policy) { + foreach ($policy->versions as $version) { + if (isset($requestedpolicies[$version->id])) { + if ($requestedpolicies[$version->id] === 1) { + $agreepolicies[] = $version->id; + } else if ($requestedpolicies[$version->id] === 0) { + $declinepolicies[] = $version->id; + } } } } diff --git a/admin/tool/policy/tests/externallib_test.php b/admin/tool/policy/tests/externallib_test.php index 1d782741fad4f..11aa5d36faaa1 100644 --- a/admin/tool/policy/tests/externallib_test.php +++ b/admin/tool/policy/tests/externallib_test.php @@ -260,12 +260,20 @@ public function test_external_get_user_acceptances() { $formdata->content_editor = ['text' => 'content', 'format' => FORMAT_HTML, 'itemid' => 0]; $optionalpolicy = api::form_policydoc_add($formdata); api::make_current($optionalpolicy->get('id')); + // Accept this version. + api::accept_policies([$optionalpolicy->get('id')], $user->id, null); + // Generate new version. + $formdata = api::form_policydoc_data($optionalpolicy); + $formdata->revision = 'v2'; + $optionalpolicynew = api::form_policydoc_update_new($formdata); + api::make_current($optionalpolicynew->get('id')); + // Now return all policies the user should be able to see, including previous versions of existing policies, if accepted/declined. $policies = \tool_policy\external\get_user_acceptances::execute(); $policies = \core_external\external_api::clean_returnvalue( \tool_policy\external\get_user_acceptances::execute_returns(), $policies); - $this->assertCount(2, $policies['policies']); + $this->assertCount(3, $policies['policies']); $this->assertCount(0, $policies['warnings']); foreach ($policies['policies'] as $policy) { if ($policy['versionid'] == $this->policy2->get('id')) { @@ -329,9 +337,19 @@ public function test_external_set_acceptances_status() { $formdata->content_editor = ['text' => 'content', 'format' => FORMAT_HTML, 'itemid' => 0]; $optionalpolicy = api::form_policydoc_add($formdata); api::make_current($optionalpolicy->get('id')); + // Decline this version. + api::decline_policies([$optionalpolicy->get('id')], $user->id, null); + // Generate new version and make it current. + $formdata = api::form_policydoc_data($optionalpolicy); + $formdata->revision = 'v2'; + $optionalpolicynew = api::form_policydoc_update_new($formdata); + api::make_current($optionalpolicynew->get('id')); - // Accept all the policies. - $ids = [['versionid' => $this->policy2->get('id'), 'status' => 1], ['versionid' => $optionalpolicy->get('id'), 'status' => 1]]; + // Accept all the current policies. + $ids = [ + ['versionid' => $this->policy2->get('id'), 'status' => 1], + ['versionid' => $optionalpolicynew->get('id'), 'status' => 1], + ]; $policies = \tool_policy\external\set_acceptances_status::execute($ids); $policies = \core_external\external_api::clean_returnvalue( @@ -340,17 +358,24 @@ public function test_external_set_acceptances_status() { $this->assertEquals(1, $policies['policyagreed']); $this->assertCount(0, $policies['warnings']); + // And now accept and old one. + $ids = [['versionid' => $optionalpolicy->get('id'), 'status' => 1],]; + $policies = \tool_policy\external\set_acceptances_status::execute($ids); + $policies = \core_external\external_api::clean_returnvalue( + \tool_policy\external\set_acceptances_status::execute_returns(), $policies); + + // Retrieve and check all are accepted now. $policies = \tool_policy\external\get_user_acceptances::execute(); $policies = \core_external\external_api::clean_returnvalue( \tool_policy\external\get_user_acceptances::execute_returns(), $policies); - $this->assertCount(2, $policies['policies']); + $this->assertCount(3, $policies['policies']); foreach ($policies['policies'] as $policy) { $this->assertEquals(1, $policy['acceptance']['status']); // Check all accepted. } // Decline optional only. - $policies = \tool_policy\external\set_acceptances_status::execute([['versionid' => $optionalpolicy->get('id'), 'status' => 0]]); + $policies = \tool_policy\external\set_acceptances_status::execute([['versionid' => $optionalpolicynew->get('id'), 'status' => 0]]); $policies = \core_external\external_api::clean_returnvalue( \tool_policy\external\set_acceptances_status::execute_returns(), $policies); @@ -361,19 +386,19 @@ public function test_external_set_acceptances_status() { $policies = \core_external\external_api::clean_returnvalue( \tool_policy\external\get_user_acceptances::execute_returns(), $policies); - $this->assertCount(2, $policies['policies']); + $this->assertCount(3, $policies['policies']); foreach ($policies['policies'] as $policy) { - if ($policy['versionid'] == $this->policy2->get('id')) { - $this->assertEquals(1, $policy['acceptance']['status']); // Still accepted. - } else { + if ($policy['versionid'] == $optionalpolicynew->get('id')) { $this->assertEquals(0, $policy['acceptance']['status']); // Not accepted. + } else { + $this->assertEquals(1, $policy['acceptance']['status']); // Accepted. } } // Parent & child case now. Accept the optional ONLY on behalf of someone else. $this->parent->policyagreed = 1; $this->setUser($this->parent); - $policies = \tool_policy\external\set_acceptances_status::execute([['versionid' => $optionalpolicy->get('id'), 'status' => 1]], $this->child->id); + $policies = \tool_policy\external\set_acceptances_status::execute([['versionid' => $optionalpolicynew->get('id'), 'status' => 1]], $this->child->id); $policies = \core_external\external_api::clean_returnvalue( \tool_policy\external\set_acceptances_status::execute_returns(), $policies); @@ -395,7 +420,7 @@ public function test_external_set_acceptances_status() { // Try to accept on behalf of other user with no permissions. $this->expectException(\required_capability_exception::class); - $policies = \tool_policy\external\set_acceptances_status::execute([['versionid' => $optionalpolicy->get('id'), 'status' => 1]], $user->id); + $policies = \tool_policy\external\set_acceptances_status::execute([['versionid' => $optionalpolicynew->get('id'), 'status' => 1]], $user->id); } /** From b4b97494173d05ee3b0713af357cde463bb0f4e8 Mon Sep 17 00:00:00 2001 From: Juan Leyva Date: Fri, 23 Feb 2024 11:28:33 +0100 Subject: [PATCH 04/22] MDL-81007 tool_policy: Allow adding notes to policies acceptances --- .../classes/external/set_acceptances_status.php | 17 ++++++++++++++--- admin/tool/policy/tests/externallib_test.php | 9 +++++++-- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/admin/tool/policy/classes/external/set_acceptances_status.php b/admin/tool/policy/classes/external/set_acceptances_status.php index 008e19014f7f1..01caae7ee206e 100644 --- a/admin/tool/policy/classes/external/set_acceptances_status.php +++ b/admin/tool/policy/classes/external/set_acceptances_status.php @@ -48,11 +48,14 @@ public static function execute_parameters(): external_function_parameters { new external_single_structure([ 'versionid' => new external_value(PARAM_INT, 'The policy version id.'), 'status' => new external_value(PARAM_INT, 'The policy acceptance status. 0: decline, 1: accept.'), + 'note' => new external_value(PARAM_NOTAGS, + 'Any comments added by a user when giving consent on behalf of another user.', VALUE_OPTIONAL, null), ]), 'Policies acceptances for the given user.' ), 'userid' => new external_value(PARAM_INT, 'The user id we want to set the acceptances. Default is the current user.', VALUE_DEFAULT, 0 ), + ] ); } @@ -91,9 +94,13 @@ public static function execute(array $policies, int $userid = 0): array { } // Split acceptances. - $requestedpolicies = $agreepolicies = $declinepolicies = []; + $requestedpolicies = $agreepolicies = $declinepolicies = $notes = []; foreach ($params['policies'] as $policy) { $requestedpolicies[$policy['versionid']] = $policy['status']; + if ($USER->id != $user->id) { + // Notes are only allowed when setting acceptances on behalf of another user. + $notes[$policy['versionid']] = $policy['note'] ?? null; + } } // Retrieve all policies and their acceptances. @@ -115,8 +122,12 @@ public static function execute(array $policies, int $userid = 0): array { api::can_decline_policies($declinepolicies, $user->id, true); // Good to go. - api::accept_policies($agreepolicies, $user->id, null); - api::decline_policies($declinepolicies, $user->id, null); + foreach ($agreepolicies as $policyversionid) { + api::accept_policies($policyversionid, $user->id, $notes[$policyversionid] ?? null); + } + foreach ($declinepolicies as $policyversionid) { + api::decline_policies($policyversionid, $user->id, $notes[$policyversionid] ?? null); + } $return = [ 'policyagreed' => (int) $user->policyagreed, // Final policy agreement status for $user. diff --git a/admin/tool/policy/tests/externallib_test.php b/admin/tool/policy/tests/externallib_test.php index 11aa5d36faaa1..b7b029ab9bb8f 100644 --- a/admin/tool/policy/tests/externallib_test.php +++ b/admin/tool/policy/tests/externallib_test.php @@ -359,7 +359,7 @@ public function test_external_set_acceptances_status() { $this->assertCount(0, $policies['warnings']); // And now accept and old one. - $ids = [['versionid' => $optionalpolicy->get('id'), 'status' => 1],]; + $ids = [['versionid' => $optionalpolicy->get('id'), 'status' => 1, 'note' => 'I accept for me.']]; // The note will be ignored. $policies = \tool_policy\external\set_acceptances_status::execute($ids); $policies = \core_external\external_api::clean_returnvalue( \tool_policy\external\set_acceptances_status::execute_returns(), $policies); @@ -372,6 +372,7 @@ public function test_external_set_acceptances_status() { $this->assertCount(3, $policies['policies']); foreach ($policies['policies'] as $policy) { $this->assertEquals(1, $policy['acceptance']['status']); // Check all accepted. + $this->assertEmpty($policy['acceptance']['note']); // The note was not recorded because it was for itself. } // Decline optional only. @@ -398,7 +399,9 @@ public function test_external_set_acceptances_status() { // Parent & child case now. Accept the optional ONLY on behalf of someone else. $this->parent->policyagreed = 1; $this->setUser($this->parent); - $policies = \tool_policy\external\set_acceptances_status::execute([['versionid' => $optionalpolicynew->get('id'), 'status' => 1]], $this->child->id); + $notetext = 'I accept this on behalf of my child Santiago.'; + $policies = \tool_policy\external\set_acceptances_status::execute( + [['versionid' => $optionalpolicynew->get('id'), 'status' => 1, 'note' => $notetext]], $this->child->id); $policies = \core_external\external_api::clean_returnvalue( \tool_policy\external\set_acceptances_status::execute_returns(), $policies); @@ -413,8 +416,10 @@ public function test_external_set_acceptances_status() { foreach ($policies['policies'] as $policy) { if ($policy['versionid'] == $this->policy2->get('id')) { $this->assertNotContains('acceptance', $policy); // Not yet accepted. + $this->assertArrayNotHasKey('acceptance', $policy); } else { $this->assertEquals(1, $policy['acceptance']['status']); // Accepted. + $this->assertEquals($notetext, $policy['acceptance']['note']); } } From 060f9714d25c40e540a0dade3be1665ae9b507cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luca=20B=C3=B6sch?= Date: Sun, 3 Mar 2024 23:20:17 +0100 Subject: [PATCH 05/22] MDL-81109 themes: Remove double lines between indented activities. --- theme/boost/scss/moodle/course.scss | 7 ------- theme/boost/style/moodle.css | 5 ----- theme/classic/style/moodle.css | 5 ----- 3 files changed, 17 deletions(-) diff --git a/theme/boost/scss/moodle/course.scss b/theme/boost/scss/moodle/course.scss index 3fdf04bf862be..7a3c5c47fa03f 100644 --- a/theme/boost/scss/moodle/course.scss +++ b/theme/boost/scss/moodle/course.scss @@ -64,16 +64,9 @@ &.indented { .activity-item { - border: 0; margin-left: map-get($spacers, 3); } } - &.indented + .indented { - .activity-item { - border-top: $border-width solid $border-color; - border-radius: unset; - } - } } .label { diff --git a/theme/boost/style/moodle.css b/theme/boost/style/moodle.css index 316487b30620f..3139ab72147ab 100644 --- a/theme/boost/style/moodle.css +++ b/theme/boost/style/moodle.css @@ -27969,13 +27969,8 @@ table.calendartable caption { color: #5babf2 !important; /* stylelint-disable-line declaration-no-important */ } .section .activity.indented .activity-item { - border: 0; margin-left: 1rem; } -.section .activity.indented + .indented .activity-item { - border-top: 1px solid #dee2e6; - border-radius: unset; -} .section .label .contentwithoutlink, .section .label .activityinstance { padding-right: 32px; diff --git a/theme/classic/style/moodle.css b/theme/classic/style/moodle.css index d28086a753b9b..e5ea8483ac12c 100644 --- a/theme/classic/style/moodle.css +++ b/theme/classic/style/moodle.css @@ -27969,13 +27969,8 @@ table.calendartable caption { color: #5babf2 !important; /* stylelint-disable-line declaration-no-important */ } .section .activity.indented .activity-item { - border: 0; margin-left: 1rem; } -.section .activity.indented + .indented .activity-item { - border-top: 1px solid #dee2e6; - border-radius: unset; -} .section .label .contentwithoutlink, .section .label .activityinstance { padding-right: 32px; From 6231d9119d4268f1253e8c191f58e47b564a5f4f Mon Sep 17 00:00:00 2001 From: Andrew Nicols Date: Fri, 8 Mar 2024 22:45:21 +0800 Subject: [PATCH 06/22] MDL-81180 core: Fix coding violations in hook manager tests --- lib/tests/hook/manager_test.php | 59 ++++++++++++++------------------- 1 file changed, 25 insertions(+), 34 deletions(-) diff --git a/lib/tests/hook/manager_test.php b/lib/tests/hook/manager_test.php index e6f2674cd2a61..77a3f55033bca 100644 --- a/lib/tests/hook/manager_test.php +++ b/lib/tests/hook/manager_test.php @@ -19,19 +19,17 @@ /** * Hooks tests. * - * @coversDefaultClass \core\hook\manager - * * @package core * @author Petr Skoda * @copyright 2022 Open LMS * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @covers \core\hook\manager */ -class manager_test extends \advanced_testcase { +final class manager_test extends \advanced_testcase { /** * Test public factory method to get hook manager. - * @covers ::get_instance */ - public function test_get_instance() { + public function test_get_instance(): void { $manager = manager::get_instance(); $this->assertInstanceOf(manager::class, $manager); @@ -40,9 +38,8 @@ public function test_get_instance() { /** * Test getting of manager test instance. - * @covers ::phpunit_get_instance */ - public function test_phpunit_get_instance() { + public function test_phpunit_get_instance(): void { $testmanager = manager::phpunit_get_instance([]); $this->assertSame([], $testmanager->get_hooks_with_callbacks()); @@ -58,13 +55,8 @@ public function test_phpunit_get_instance() { /** * Test loading and parsing of callbacks from files. - * - * @covers ::get_callbacks_for_hook - * @covers ::get_hooks_with_callbacks - * @covers ::load_callbacks - * @covers ::add_component_callbacks */ - public function test_callbacks() { + public function test_callbacks(): void { $componentfiles = [ 'test_plugin1' => __DIR__ . '/../fixtures/hook/hooks1_valid.php', 'test_plugin2' => __DIR__ . '/../fixtures/hook/hooks2_valid.php', @@ -94,18 +86,23 @@ public function test_callbacks() { $this->assertSame([], $testmanager->get_hooks_with_callbacks()); $debuggings = $this->getDebuggingMessages(); $this->resetDebugging(); - $this->assertSame('Hook callback definition requires \'hook\' name in \'test_plugin1\'', - $debuggings[0]->message); - $this->assertSame('Hook callback definition requires \'callback\' callable in \'test_plugin1\'', - $debuggings[1]->message); - $this->assertSame('Hook callback definition contains invalid \'callback\' static class method string in \'test_plugin1\'', - $debuggings[2]->message); + $this->assertSame( + 'Hook callback definition requires \'hook\' name in \'test_plugin1\'', + $debuggings[0]->message + ); + $this->assertSame( + 'Hook callback definition requires \'callback\' callable in \'test_plugin1\'', + $debuggings[1]->message + ); + $this->assertSame( + 'Hook callback definition contains invalid \'callback\' static class method string in \'test_plugin1\'', + $debuggings[2]->message + ); $this->assertCount(3, $debuggings); } /** * Test hook dispatching, that is callback execution. - * @covers ::dispatch */ public function test_dispatch(): void { require_once(__DIR__ . '/../fixtures/hook/hook.php'); @@ -127,7 +124,6 @@ public function test_dispatch(): void { /** * Test hook dispatching, that is callback execution. - * @covers ::dispatch */ public function test_dispatch_with_exception(): void { require_once(__DIR__ . '/../fixtures/hook/hook.php'); @@ -149,7 +145,6 @@ public function test_dispatch_with_exception(): void { /** * Test hook dispatching, that is callback execution. - * @covers ::dispatch */ public function test_dispatch_with_invalid(): void { // Missing callbacks is ignored. @@ -171,9 +166,8 @@ public function test_dispatch_with_invalid(): void { /** * Test stoppping of hook dispatching. - * @covers ::dispatch */ - public function test_dispatch_stoppable() { + public function test_dispatch_stoppable(): void { require_once(__DIR__ . '/../fixtures/hook/stoppablehook.php'); require_once(__DIR__ . '/../fixtures/hook/callbacks.php'); @@ -193,10 +187,8 @@ public function test_dispatch_stoppable() { /** * Tests callbacks can be overridden via CFG settings. - * @covers ::load_callbacks - * @covers ::dispatch */ - public function test_callback_overriding() { + public function test_callback_overriding(): void { global $CFG; $this->resetAfterTest(); @@ -224,8 +216,8 @@ public function test_callback_overriding() { $CFG->hooks_callback_overrides = [ 'test_plugin\\hook\\hook' => [ - 'test_plugin\\callbacks::test2' => ['priority' => 33] - ] + 'test_plugin\\callbacks::test2' => ['priority' => 33], + ], ]; $testmanager = manager::phpunit_get_instance($componentfiles); @@ -249,8 +241,8 @@ public function test_callback_overriding() { $CFG->hooks_callback_overrides = [ 'test_plugin\\hook\\hook' => [ - 'test_plugin\\callbacks::test2' => ['priority' => 33, 'disabled' => true] - ] + 'test_plugin\\callbacks::test2' => ['priority' => 33, 'disabled' => true], + ], ]; $testmanager = manager::phpunit_get_instance($componentfiles); $this->assertSame(['test_plugin\\hook\\hook'], $testmanager->get_hooks_with_callbacks()); @@ -262,8 +254,7 @@ public function test_callback_overriding() { 'component' => 'test_plugin1', 'disabled' => false, 'priority' => 100, - ], - $callbacks[0]); + ], $callbacks[0]); $this->assertSame([ 'callback' => 'test_plugin\\callbacks::test2', 'component' => 'test_plugin2', @@ -275,7 +266,7 @@ public function test_callback_overriding() { $CFG->hooks_callback_overrides = [ 'test_plugin\\hook\\hook' => [ 'test_plugin\\callbacks::test2' => ['disabled' => true], - ] + ], ]; $testmanager = manager::phpunit_get_instance($componentfiles); $this->assertSame(['test_plugin\\hook\\hook'], $testmanager->get_hooks_with_callbacks()); From bbc98c82c07d5f1e690fbd844a262f3f9a93cab4 Mon Sep 17 00:00:00 2001 From: Andrew Nicols Date: Fri, 8 Mar 2024 22:46:06 +0800 Subject: [PATCH 07/22] MDL-81180 core: Add support for array notation in hook callback --- admin/tool/mobile/db/hooks.php | 4 ++-- lib/classes/hook/manager.php | 7 +++++++ lib/tests/fixtures/hook/hooks1_valid.php | 2 +- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/admin/tool/mobile/db/hooks.php b/admin/tool/mobile/db/hooks.php index 60e2d927e8c8b..aebed53b97b6b 100644 --- a/admin/tool/mobile/db/hooks.php +++ b/admin/tool/mobile/db/hooks.php @@ -26,8 +26,8 @@ $callbacks = [ [ - 'hook' => core\hook\output\standard_head_html_prepend::class, - 'callback' => 'tool_mobile\local\hooks\output\standard_head_html_prepend::callback', + 'hook' => \core\hook\output\standard_head_html_prepend::class, + 'callback' => [\tool_mobile\local\hooks\output\standard_head_html_prepend::class, 'callback'], 'priority' => 0, ], ]; diff --git a/lib/classes/hook/manager.php b/lib/classes/hook/manager.php index e793c5810e68f..68475fa670733 100644 --- a/lib/classes/hook/manager.php +++ b/lib/classes/hook/manager.php @@ -546,6 +546,13 @@ private function normalise_callback(string $component, array $callback): ?string return null; } $classmethod = $callback['callback']; + if (is_array($classmethod)) { + if (count($classmethod) !== 2) { + debugging("Hook callback definition contains invalid 'callback' array in '$component'", DEBUG_DEVELOPER); + return null; + } + $classmethod = implode('::', $classmethod); + } if (!is_string($classmethod)) { debugging("Hook callback definition contains invalid 'callback' string in '$component'", DEBUG_DEVELOPER); return null; diff --git a/lib/tests/fixtures/hook/hooks1_valid.php b/lib/tests/fixtures/hook/hooks1_valid.php index 78266ac489089..4559902229fd2 100644 --- a/lib/tests/fixtures/hook/hooks1_valid.php +++ b/lib/tests/fixtures/hook/hooks1_valid.php @@ -28,6 +28,6 @@ $callbacks = [ [ 'hook' => 'test_plugin\\hook\\hook', - 'callback' => 'test_plugin\\callbacks::test1', + 'callback' => [\test_plugin\callbacks::class, 'test1'], ], ]; From 2e138101ff2a5467d5b7849e4821a906c9af506a Mon Sep 17 00:00:00 2001 From: Laurent David Date: Thu, 18 Jan 2024 09:57:31 +0100 Subject: [PATCH 08/22] MDL-80565 report: Revert MDL-41465 changes * Partial revert of changes made in MDL-41465, while keeping the API changes made in enrol/lib. --- report/log/classes/renderable.php | 57 ++-------- report/log/tests/renderable_test.php | 158 -------------------------- report/loglive/classes/renderable.php | 11 -- report/loglive/classes/table_log.php | 17 +-- 4 files changed, 13 insertions(+), 230 deletions(-) delete mode 100644 report/log/tests/renderable_test.php diff --git a/report/log/classes/renderable.php b/report/log/classes/renderable.php index 8bf42f521ac82..76433fa6515b1 100644 --- a/report/log/classes/renderable.php +++ b/report/log/classes/renderable.php @@ -93,9 +93,6 @@ class report_log_renderable implements renderable { /** @var table_log table log which will be used for rendering logs */ public $tablelog; - /** @var array group ids */ - public $grouplist; - /** * Constructor. * @@ -346,40 +343,30 @@ public function get_course_list() { } /** - * Return list of groups that are used in this course. This is done when groups are used in the course - * and the user is allowed to see all groups or groups are visible anyway. If groups are used but the - * mode is separate groups and the user is not allowed to see all groups, the list contains the groups - * only, where the user is member. - * If the course uses no groups, the list is empty. + * Return list of groups. * * @return array list of groups. */ public function get_group_list() { - global $USER; - if ($this->grouplist !== null) { - return $this->grouplist; - } // No groups for system. if (empty($this->course)) { - $this->grouplist = []; - return $this->grouplist; + return array(); } $context = context_course::instance($this->course->id); - $this->grouplist = []; + $groups = array(); $groupmode = groups_get_course_groupmode($this->course); - $cgroups = []; if (($groupmode == VISIBLEGROUPS) || - ($groupmode == SEPARATEGROUPS && has_capability('moodle/site:accessallgroups', $context))) { - $cgroups = groups_get_all_groups($this->course->id); - } else if ($groupmode == SEPARATEGROUPS && !has_capability('moodle/site:accessallgroups', $context)) { - $cgroups = groups_get_all_groups($this->course->id, $USER->id); - } - foreach ($cgroups as $cgroup) { - $this->grouplist[$cgroup->id] = $cgroup->name; + ($groupmode == SEPARATEGROUPS and has_capability('moodle/site:accessallgroups', $context))) { + // Get all groups. + if ($cgroups = groups_get_all_groups($this->course->id)) { + foreach ($cgroups as $cgroup) { + $groups[$cgroup->id] = $cgroup->name; + } + } } - return $this->grouplist; + return $groups; } /** @@ -398,29 +385,9 @@ public function get_user_list() { $limitfrom = empty($this->showusers) ? 0 : ''; $limitnum = empty($this->showusers) ? COURSE_MAX_USERS_PER_DROPDOWN + 1 : ''; $userfieldsapi = \core_user\fields::for_name(); - - // Get the groups of that course. - $groups = $this->get_group_list(); - // Check here if we are not in group mode, or in group mode but narrow the group selection - // to the group of the user. - if (empty($groups) || !empty($this->groupid) && isset($groups[(int)$this->groupid])) { - // No groups are used in that course, therefore get all users (maybe limited to one group). - $courseusers = get_enrolled_users($context, '', $this->groupid, 'u.id, ' . + $courseusers = get_enrolled_users($context, '', $this->groupid, 'u.id, ' . $userfieldsapi->get_sql('u', false, '', '', false)->selects, null, $limitfrom, $limitnum); - } else { - // The course uses groups, get the users from these groups. - $groupids = array_keys($groups); - try { - $enrolments = enrol_get_course_users($courseid, false, [], [], $groupids); - $courseusers = []; - foreach ($enrolments as $enrolment) { - $courseusers[$enrolment->id] = $enrolment; - } - } catch (Exception $e) { - $courseusers = []; - } - } if (count($courseusers) < COURSE_MAX_USERS_PER_DROPDOWN && !$this->showusers) { $this->showusers = 1; diff --git a/report/log/tests/renderable_test.php b/report/log/tests/renderable_test.php deleted file mode 100644 index 97e09a58ba335..0000000000000 --- a/report/log/tests/renderable_test.php +++ /dev/null @@ -1,158 +0,0 @@ -. - -namespace report_log; - -/** - * Class report_log\renderable_test to cover functions in \report_log_renderable. - * - * @package report_log - * @copyright 2023 Stephan Robotta - * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later. - */ -class renderable_test extends \advanced_testcase { - - /** - * @var [stdClass] The students. - */ - private $student = []; - - /** - * @var [stdClass] The teachers. - */ - private $teacher = []; - - /** - * @var [stdClass] The groups. - */ - private $group = []; - - /** - * @var stdClass The course. - */ - private $course; - - /** - * Set up a course with two groups, three students being each in one of the groups, - * two teachers each in either group while the second teacher is also member of the other group. - * @return void - * @throws \coding_exception - */ - public function setUp(): void { - global $PAGE; - $this->course = $this->getDataGenerator()->create_course(['groupmode' => 1]); - $this->group[] = $this->getDataGenerator()->create_group(['courseid' => $this->course->id]); - $this->group[] = $this->getDataGenerator()->create_group(['courseid' => $this->course->id]); - - for ($i = 0; $i < 3; $i++) { - $this->student[$i] = $this->getDataGenerator()->create_user(); - $this->getDataGenerator()->enrol_user($this->student[$i]->id, $this->course->id, 'student'); - $this->getDataGenerator()->create_group_member([ - 'groupid' => $this->group[$i % 2]->id, - 'userid' => $this->student[$i]->id, - ]); - } - for ($i = 0; $i < 2; $i++) { - $this->teacher[$i] = $this->getDataGenerator()->create_user(); - $this->getDataGenerator()->enrol_user($this->teacher[$i]->id, $this->course->id, 'editingteacher'); - $this->getDataGenerator()->create_group_member([ - 'groupid' => $this->group[$i]->id, - 'userid' => $this->teacher[$i]->id, - ]); - } - // Make teacher2 also member of group1. - $this->getDataGenerator()->create_group_member([ - 'groupid' => $this->group[0]->id, - 'userid' => $this->teacher[1]->id, - ]); - - $PAGE->set_url('/report/log/index.php?id=' . $this->course->id); - $this->resetAfterTest(); - } - - /** - * Test report_log_renderable::get_user_list(). - * @covers \report_log_renderable::get_user_list - * @return void - */ - public function test_get_user_list() { - // Fetch all users of group 1 and the guest user. - $userids = $this->fetch_users_from_renderable((int)$this->student[0]->id); - $this->assertCount(5, $userids); - $this->assertContains((int)$this->student[0]->id, $userids); // His own group (group 1). - $this->assertNotContains((int)$this->student[1]->id, $userids); // He is in group 2. - $this->assertContains((int)$this->teacher[0]->id, $userids); // He is in group 1. - $this->assertContains((int)$this->teacher[1]->id, $userids); // He is in both groups. - - // Fetch users of all groups and the guest user. The teacher has the capability moodle/site:accessallgroups. - $this->setUser($this->teacher[1]->id); - $renderable = new \report_log_renderable("", (int)$this->course->id, $this->teacher[1]->id); - $users = $renderable->get_user_list(); - $this->assertCount(6, $users); - - // Fetch users of group 2 and the guest user. - $userids = $this->fetch_users_from_renderable((int)$this->student[1]->id); - $this->assertCount( 3, $userids); - $this->assertNotContains((int)$this->student[0]->id, $userids); - $this->assertContains((int)$this->student[1]->id, $userids); - $this->assertNotContains((int)$this->teacher[0]->id, $userids); - $this->assertContains((int)$this->teacher[1]->id, $userids); - - // Fetch users of group 2 and test user as teacher2 but limited to his group. - $userids = $this->fetch_users_from_renderable((int)$this->teacher[1]->id, (int)$this->group[1]->id); - $this->assertCount( 3, $userids); - $this->assertNotContains((int)$this->student[0]->id, $userids); - $this->assertContains((int)$this->student[1]->id, $userids); - $this->assertNotContains((int)$this->teacher[0]->id, $userids); - $this->assertContains((int)$this->teacher[1]->id, $userids); - - } - - /** - * Helper function to return a list of user ids from the renderable object. - * @param int $userid - * @param ?int $groupid - * @return array - */ - protected function fetch_users_from_renderable(int $userid, ?int $groupid = 0): array { - $this->setUser($userid); - $renderable = new \report_log_renderable( - "", (int)$this->course->id, $userid, 0, '', $groupid); - $users = $renderable->get_user_list(); - return \array_keys($users); - } - - /** - * Test report_log_renderable::get_group_list(). - * @covers \report_log_renderable::get_group_list - * @return void - */ - public function test_get_group_list() { - - // The student sees his own group only. - $this->setUser($this->student[0]->id); - $renderable = new \report_log_renderable("", (int)$this->course->id, $this->student[0]->id); - $groups = $renderable->get_group_list(); - $this->assertCount(1, $groups); - - // While the teacher is allowed to see all groups. - $this->setUser($this->teacher[0]->id); - $renderable = new \report_log_renderable("", (int)$this->course->id, $this->teacher[0]->id); - $groups = $renderable->get_group_list(); - $this->assertCount(2, $groups); - - } -} diff --git a/report/loglive/classes/renderable.php b/report/loglive/classes/renderable.php index 06e26904e1586..4c094c19ab00f 100644 --- a/report/loglive/classes/renderable.php +++ b/report/loglive/classes/renderable.php @@ -168,8 +168,6 @@ protected function setup_table_ajax() { * @return stdClass filters */ protected function setup_filters() { - global $USER; - $readers = $this->get_readers(); // Set up filters. @@ -180,15 +178,6 @@ protected function setup_filters() { if (!has_capability('moodle/site:viewanonymousevents', $context)) { $filter->anonymous = 0; } - if (groups_get_course_groupmode($this->course) == SEPARATEGROUPS && - !has_capability('moodle/site:accessallgroups', $context)) { - if ($cgroups = groups_get_all_groups($this->course->id, $USER->id)) { - $filter->groups = []; - foreach ($cgroups as $cgroup) { - $filter->groups[] = (int)$cgroup->id; - } - } - } } else { $filter->courseid = 0; } diff --git a/report/loglive/classes/table_log.php b/report/loglive/classes/table_log.php index 7ab1b9c5835cb..19ca5e1743cc7 100644 --- a/report/loglive/classes/table_log.php +++ b/report/loglive/classes/table_log.php @@ -56,7 +56,6 @@ class report_loglive_table_log extends table_sql { * - int userid: user id * - int|string modid: Module id or "site_errors" to view site errors * - int groupid: Group id - * - array groups: List of group ids * - \core\log\sql_reader logreader: reader from which data will be fetched. * - int edulevel: educational level. * - string action: view action @@ -299,29 +298,14 @@ protected function action_link(moodle_url $url, $text, $name = 'popup') { * @param bool $useinitialsbar do you want to use the initials bar. */ public function query_db($pagesize, $useinitialsbar = true) { - global $DB; $joins = array(); $params = array(); // Set up filtering. if (!empty($this->filterparams->courseid)) { - // For a normal course, set the course filter. $joins[] = "courseid = :courseid"; $params['courseid'] = $this->filterparams->courseid; - // If we have a course, then check if the groups filter is set. - if ($this->filterparams->courseid != SITEID && !empty($this->filterparams->groups)) { - // If that's the case, limit the users to be in the groups only, defined by the filter. - $useringroups = []; - foreach ($this->filterparams->groups as $groupid) { - $gusers = groups_get_members($groupid, 'u.id'); - $useringroups = array_merge($useringroups, array_keys($gusers)); - } - $useringroups = array_unique($useringroups); - list($ugsql, $ugparams) = $DB->get_in_or_equal($useringroups, SQL_PARAMS_NAMED); - $joins[] = 'userid ' . $ugsql; - $params = array_merge($params, $ugparams); - } } if (!empty($this->filterparams->date)) { @@ -333,6 +317,7 @@ public function query_db($pagesize, $useinitialsbar = true) { $joins[] = "anonymous = :anon"; $params['anon'] = $this->filterparams->anonymous; } + $selector = implode(' AND ', $joins); $total = $this->filterparams->logreader->get_events_select_count($selector, $params); From f92d5f9ca8719c6ac1f8ed0c353dc4dec3e8e8a9 Mon Sep 17 00:00:00 2001 From: Laurent David Date: Tue, 16 Jan 2024 13:01:28 +0100 Subject: [PATCH 09/22] MDL-80565 report_log: Fix report log selector * When using the report log and we select a group, the group list should show the right list of users --- report/log/classes/renderable.php | 66 +++-- report/log/tests/renderable_test.php | 383 +++++++++++++++++++++++++++ 2 files changed, 433 insertions(+), 16 deletions(-) create mode 100644 report/log/tests/renderable_test.php diff --git a/report/log/classes/renderable.php b/report/log/classes/renderable.php index 76433fa6515b1..a7e28132a8498 100644 --- a/report/log/classes/renderable.php +++ b/report/log/classes/renderable.php @@ -93,6 +93,13 @@ class report_log_renderable implements renderable { /** @var table_log table log which will be used for rendering logs */ public $tablelog; + /** + * @var array group ids + * @deprecated since Moodle 4.4 - please do not use this public property + * @todo MDL-81155 remove this property as it is not used anymore. + */ + public $grouplist; + /** * Constructor. * @@ -343,30 +350,35 @@ public function get_course_list() { } /** - * Return list of groups. + * Return list of groups that are used in this course. This is done when groups are used in the course + * and the user is allowed to see all groups or groups are visible anyway. If groups are used but the + * mode is separate groups and the user is not allowed to see all groups, the list contains the groups + * only, where the user is member. + * If the course uses no groups, the list is empty. * * @return array list of groups. */ public function get_group_list() { + global $USER; // No groups for system. if (empty($this->course)) { - return array(); + return []; } $context = context_course::instance($this->course->id); - $groups = array(); $groupmode = groups_get_course_groupmode($this->course); - if (($groupmode == VISIBLEGROUPS) || - ($groupmode == SEPARATEGROUPS and has_capability('moodle/site:accessallgroups', $context))) { - // Get all groups. - if ($cgroups = groups_get_all_groups($this->course->id)) { - foreach ($cgroups as $cgroup) { - $groups[$cgroup->id] = $cgroup->name; - } - } + $grouplist = []; + $userid = $groupmode == SEPARATEGROUPS ? $USER->id : 0; + if (has_capability('moodle/site:accessallgroups', $context)) { + $userid = 0; + } + $cgroups = groups_get_all_groups($this->course->id, $userid); + if (!empty($cgroups)) { + $grouplist = array_column($cgroups, 'name', 'id'); } - return $groups; + $this->grouplist = $grouplist; // Keep compatibility with MDL-41465. + return $grouplist; } /** @@ -383,11 +395,33 @@ public function get_user_list() { } $context = context_course::instance($courseid); $limitfrom = empty($this->showusers) ? 0 : ''; - $limitnum = empty($this->showusers) ? COURSE_MAX_USERS_PER_DROPDOWN + 1 : ''; + $limitnum = empty($this->showusers) ? COURSE_MAX_USERS_PER_DROPDOWN + 1 : ''; $userfieldsapi = \core_user\fields::for_name(); - $courseusers = get_enrolled_users($context, '', $this->groupid, 'u.id, ' . - $userfieldsapi->get_sql('u', false, '', '', false)->selects, - null, $limitfrom, $limitnum); + + // Get the groups of that course that the user can see. + $groups = $this->get_group_list(); + $groupids = array_keys($groups); + // Now doublecheck the value of groupids and deal with special case like USERWITHOUTGROUP. + $groupmode = groups_get_course_groupmode($this->course); + if ( + has_capability('moodle/site:accessallgroups', $context) + || $groupmode != SEPARATEGROUPS + || empty($groupids) + ) { + $groupids[] = USERSWITHOUTGROUP; + } + // First case, the user has selected a group and user is in this group. + if ($this->groupid > 0) { + if (!isset($groups[$this->groupid])) { + // The user is not in this group, so we will ignore the group selection. + $groupids = 0; + } else { + $groupids = [$this->groupid]; + } + } + $courseusers = get_enrolled_users($context, '', $groupids, 'u.id, ' . + $userfieldsapi->get_sql('u', false, '', '', false)->selects, + null, $limitfrom, $limitnum); if (count($courseusers) < COURSE_MAX_USERS_PER_DROPDOWN && !$this->showusers) { $this->showusers = 1; diff --git a/report/log/tests/renderable_test.php b/report/log/tests/renderable_test.php new file mode 100644 index 0000000000000..b3a25d70e1663 --- /dev/null +++ b/report/log/tests/renderable_test.php @@ -0,0 +1,383 @@ +. + +namespace report_log; + +use context_course; +use core_user; + +/** + * Class report_log\renderable_test to cover functions in \report_log_renderable. + * + * @package report_log + * @copyright 2023 Stephan Robotta + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later. + */ +class renderable_test extends \advanced_testcase { + /** + * @var int The course with separate groups. + */ + const COURSE_SEPARATE_GROUP = 0; + /** + * @var int The course with separate groups. + */ + const COURSE_VISIBLE_GROUP = 1; + /** + * @var int The course with separate groups. + */ + const COURSE_NO_GROUP = 2; + /** + * @var array The setup of users. + */ + const SETUP_USER_DEFS = [ + // Make student2 also member of group1. + 'student' => [ + 'student0' => ['group0'], + 'student1' => ['group1'], + 'student2' => ['group0', 'group1'], + 'student3' => [], + ], + // Make teacher2 also member of group1. + 'teacher' => [ + 'teacher0' => ['group0'], + 'teacher1' => ['group1'], + 'teacher2' => ['group0', 'group1'], + ], + // Make editingteacher also member of group1. + 'editingteacher' => [ + 'editingteacher0' => ['group0'], + 'editingteacher1' => ['group1'], + 'editingteacher2' => ['group0', 'group1'], + ], + ]; + /** + * @var array|\stdClass all users indexed by username. + */ + private $users = []; + /** + * @var array The groups by courses (array of array). + */ + private $groupsbycourse = []; + /** + * @var array The courses. + */ + private $courses; + + /** + * Get the data provider for test_get_user_list(). + * + * @return array + */ + public static function get_user_visibility_list_provider(): array { + return [ + 'separategroups: student 0' => [ + self::COURSE_SEPARATE_GROUP, + 'student0', + // All users in group 0. + [ + 'student0', 'student2', + 'teacher0', 'teacher2', + 'editingteacher0', 'editingteacher2', + ], + ], + 'separategroups: student 1' => [ + self::COURSE_SEPARATE_GROUP, + 'student1', + // All users in group 1. + [ + 'student1', 'student2', 'teacher1', 'teacher2', 'editingteacher1', + 'editingteacher2', + ], + ], + 'separategroups: editing teacher 0' => [ + self::COURSE_SEPARATE_GROUP, + 'editingteacher0', + // All users (including student3 who is not in a group). + [ + 'student0', 'student1', 'student2', 'student3', + 'teacher0', 'teacher1', 'teacher2', + 'editingteacher0', 'editingteacher1', 'editingteacher2', + ], + ], + 'separategroups: teacher 0' => [ + self::COURSE_SEPARATE_GROUP, + 'teacher0', + // All users in group 0. + [ + 'student0', 'student2', + 'teacher0', 'teacher2', + 'editingteacher0', 'editingteacher2', + ], + ], + 'separategroups: teacher 2' => [ + self::COURSE_SEPARATE_GROUP, + 'teacher2', + // All users in group 0 and 1. + [ + 'student0', 'student1', 'student2', + 'teacher0', 'teacher1', 'teacher2', + 'editingteacher0', 'editingteacher1', 'editingteacher2', + ], + ], + 'separategroups: teacher 2 with group0 selected' => [ + self::COURSE_SEPARATE_GROUP, + 'teacher2', + // All users in group 0. + [ + 'student0', 'student2', + 'teacher0', 'teacher2', + 'editingteacher0', 'editingteacher2', + ], + 'group0', + ], + 'separategroups: teacher 2 with group1 selected' => [ + self::COURSE_SEPARATE_GROUP, + 'teacher2', + // All users in group 1. + [ + 'student1', 'student2', + 'teacher1', 'teacher2', + 'editingteacher1', 'editingteacher2', + ], + 'group1', + ], + 'visiblegroup: teacher 0 with group1 selected' => [ + self::COURSE_VISIBLE_GROUP, + 'teacher2', + // All users in group 1. + [ + 'student1', 'student2', + 'teacher1', 'teacher2', + 'editingteacher1', 'editingteacher2', + ], + 'group1', + ], + 'visiblegroup: teacher 0 without group selected' => [ + self::COURSE_VISIBLE_GROUP, + 'teacher2', + // All users. + [ + 'student0', 'student1', 'student2', 'student3', + 'teacher0', 'teacher1', 'teacher2', + 'editingteacher0', 'editingteacher1', 'editingteacher2', + ], + ], + 'visiblegroup: editing teacher' => [ + self::COURSE_VISIBLE_GROUP, + 'editingteacher0', + // All users. + [ + 'student0', 'student1', 'student2', 'student3', + 'teacher0', 'teacher1', 'teacher2', + 'editingteacher0', 'editingteacher1', 'editingteacher2', + ], + ], + 'visiblegroup: student' => [ + self::COURSE_VISIBLE_GROUP, + 'student0', + // All users. + [ + 'student0', 'student1', 'student2', 'student3', + 'teacher0', 'teacher1', 'teacher2', + 'editingteacher0', 'editingteacher1', 'editingteacher2', + ], + ], + 'nogroup: teacher 0' => [ + self::COURSE_VISIBLE_GROUP, + 'teacher2', + // All users. + [ + 'student0', 'student1', 'student2', 'student3', + 'teacher0', 'teacher1', 'teacher2', + 'editingteacher0', 'editingteacher1', 'editingteacher2', + ], + ], + 'nogroup: editing teacher 0' => [ + self::COURSE_VISIBLE_GROUP, + 'editingteacher0', + // All users. + [ + 'student0', 'student1', 'student2', 'student3', + 'teacher0', 'teacher1', 'teacher2', + 'editingteacher0', 'editingteacher1', 'editingteacher2', + ], + ], + 'nogroup: student' => [ + self::COURSE_VISIBLE_GROUP, + 'student0', + // All users. + [ + 'student0', 'student1', 'student2', 'student3', + 'teacher0', 'teacher1', 'teacher2', + 'editingteacher0', 'editingteacher1', 'editingteacher2', + ], + ], + ]; + } + + /** + * Data provider for test_get_group_list(). + * + * @return array + */ + public static function get_group_list_provider(): array { + return [ + // The student sees his own group only. + 'separategroup: student in one group' => [self::COURSE_SEPARATE_GROUP, 'student0', 1], + 'separategroup: student in two groups' => [self::COURSE_SEPARATE_GROUP, 'student2', 2], + // While the teacher is not allowed to see all groups. + 'separategroup: teacher in one group' => [self::COURSE_SEPARATE_GROUP, 'teacher0', 1], + 'separategroup: teacher in two groups' => [self::COURSE_SEPARATE_GROUP, 'teacher2', 2], + // But editing teacher should see all. + 'separategroup: editingteacher' => [self::COURSE_SEPARATE_GROUP, 'editingteacher0', 2], + // The student sees all groups. + 'visiblegroup: student in one group' => [self::COURSE_VISIBLE_GROUP, 'student0', 2], + // Same for teacher. + 'visiblegroup: teacher in one group' => [self::COURSE_VISIBLE_GROUP, 'teacher0', 2], + // And editing teacher. + 'visiblegroup: editingteacher' => [self::COURSE_VISIBLE_GROUP, 'editingteacher0', 2], + // No group. + 'nogroups: student in one group' => [self::COURSE_NO_GROUP, 'student0', 0], + // Same for teacher. + 'nogroups: teacher in one group' => [self::COURSE_NO_GROUP, 'teacher0', 0], + // And editing teacher. + 'nogroups: editingteacher' => [self::COURSE_NO_GROUP, 'editingteacher0', 0], + ]; + } + + /** + * Set up a course with two groups, three students being each in one of the groups, + * two teachers each in either group while the second teacher is also member of the other group. + * + * @return void + * @throws \coding_exception + */ + public function setUp(): void { + $this->resetAfterTest(); + $this->courses[self::COURSE_SEPARATE_GROUP] = $this->getDataGenerator()->create_course(['groupmode' => SEPARATEGROUPS]); + $this->courses[self::COURSE_VISIBLE_GROUP] = $this->getDataGenerator()->create_course(['groupmode' => VISIBLEGROUPS]); + $this->courses[self::COURSE_NO_GROUP] = $this->getDataGenerator()->create_course(); + + foreach ($this->courses as $coursetype => $course) { + if ($coursetype == self::COURSE_NO_GROUP) { + continue; + } + $this->groupsbycourse[$coursetype] = []; + $this->groupsbycourse[$coursetype]['group0'] = + $this->getDataGenerator()->create_group(['courseid' => $course->id, 'name' => 'group0']); + $this->groupsbycourse[$coursetype]['group1'] = + $this->getDataGenerator()->create_group(['courseid' => $course->id, 'name' => 'group1']); + } + + foreach (self::SETUP_USER_DEFS as $role => $userdefs) { + foreach ($userdefs as $username => $groups) { + $user = $this->getDataGenerator()->create_user( + [ + 'username' => $username, + 'firstname' => "FN{$role}{$username}", + 'lastname' => "LN{$role}{$username}", + ]); + foreach ($this->courses as $coursetype => $course) { + $this->getDataGenerator()->enrol_user($user->id, $course->id, $role); + foreach ($groups as $groupname) { + if ($coursetype == self::COURSE_NO_GROUP) { + continue; + } + $this->getDataGenerator()->create_group_member([ + 'groupid' => $this->groupsbycourse[$coursetype][$groupname]->id, + 'userid' => $user->id, + ]); + } + } + $this->users[$username] = $user; + } + } + } + + /** + * Test report_log_renderable::get_user_list(). + * + * @param int $courseindex + * @param string $username + * @param array $expectedusers + * @param string|null $groupname + * @covers \report_log_renderable::get_user_list + * @dataProvider get_user_visibility_list_provider + * @return void + */ + public function test_get_user_list(int $courseindex, string $username, array $expectedusers, + string $groupname = null): void { + global $PAGE, $CFG; + $currentcourse = $this->courses[$courseindex]; + $PAGE->set_url('/report/log/index.php?id=' . $currentcourse->id); + // Fetch all users of group 1 and the guest user. + $currentuser = $this->users[$username]; + $this->setUser($currentuser->id); + $groupid = 0; + if ($groupname) { + $groupid = $this->groupsbycourse[$courseindex][$groupname]->id; + } + $renderable = new \report_log_renderable( + "", (int) $currentcourse->id, $currentuser->id, 0, '', $groupid); + $userlist = $renderable->get_user_list(); + unset($userlist[$CFG->siteguest]); // We ignore guest. + $usersid = array_keys($userlist); + + $users = array_map(function($userid) { + return core_user::get_user($userid); + }, $usersid); + + // Now check that the users are the expected ones. + asort($expectedusers); + $userlistbyname = array_column($users, 'username'); + asort($userlistbyname); + $this->assertEquals(array_values($expectedusers), array_values($userlistbyname)); + + // Check that users are in order lastname > firstname > id. + $sortedusers = $users; + // Sort user by lastname > firstname > id. + usort($sortedusers, function($a, $b) { + if ($a->lastname != $b->lastname) { + return $a->lastname <=> $b->lastname; + } + if ($a->firstname != $b->firstname) { + return $a->firstname <=> $b->firstname; + } + return $a->id <=> $b->id; + }); + + $sortedusernames = array_column($sortedusers, 'username'); + $userlistbyname = array_column($users, 'username'); + $this->assertEquals($sortedusernames, $userlistbyname); + + } + + /** + * Test report_log_renderable::get_group_list(). + * + * @covers \report_log_renderable::get_group_list + * @dataProvider get_group_list_provider + * @return void + */ + public function test_get_group_list($courseindex, $username, $expectedcount): void { + global $PAGE; + $PAGE->set_url('/report/log/index.php?id=' . $this->courses[$courseindex]->id); + $this->setUser($this->users[$username]->id); + $renderable = new \report_log_renderable("", (int) $this->courses[$courseindex]->id, $this->users[$username]->id); + $groups = $renderable->get_group_list(); + $this->assertCount($expectedcount, $groups); + } +} From e899219c206b92e5c54b0f23cd5c41c4bcd22c23 Mon Sep 17 00:00:00 2001 From: Laurent David Date: Tue, 23 Jan 2024 09:11:12 +0100 Subject: [PATCH 10/22] MDL-80565 report_log: Prevent user from seeing other logs * When no groups are selected we forcefully prevent users from seeing users' logs from other groups --- report/log/classes/renderable.php | 22 +++-------- report/log/classes/table_log.php | 38 +++++++++++++++---- report/log/tests/renderable_test.php | 57 ++++++++++++++++++++++++++++ 3 files changed, 92 insertions(+), 25 deletions(-) diff --git a/report/log/classes/renderable.php b/report/log/classes/renderable.php index a7e28132a8498..34ce7dfb60fa0 100644 --- a/report/log/classes/renderable.php +++ b/report/log/classes/renderable.php @@ -258,26 +258,14 @@ public function get_selected_group() { // Setup for group handling. $groupmode = groups_get_course_groupmode($this->course); if ($groupmode == SEPARATEGROUPS and !has_capability('moodle/site:accessallgroups', $context)) { - $selectedgroup = -1; - } else if ($groupmode) { - $selectedgroup = $this->groupid; - } else { - $selectedgroup = 0; - } - - if ($selectedgroup === -1) { if (isset($SESSION->currentgroup[$this->course->id])) { $selectedgroup = $SESSION->currentgroup[$this->course->id]; - } else { - $selectedgroup = groups_get_all_groups($this->course->id, $USER->id); - if (is_array($selectedgroup)) { - $groupids = array_keys($selectedgroup); - $selectedgroup = array_shift($groupids); - $SESSION->currentgroup[$this->course->id] = $selectedgroup; - } else { - $selectedgroup = 0; - } + } else if ($this->groupid > 0) { + $SESSION->currentgroup[$this->course->id] = $this->groupid; + $selectedgroup = $this->groupid; } + } else if ($groupmode) { + $selectedgroup = $this->groupid; } return $selectedgroup; } diff --git a/report/log/classes/table_log.php b/report/log/classes/table_log.php index e64dfad026741..4cb1da64d1b5b 100644 --- a/report/log/classes/table_log.php +++ b/report/log/classes/table_log.php @@ -23,7 +23,8 @@ */ defined('MOODLE_INTERNAL') || die; - +global $CFG; +require_once($CFG->libdir . '/tablelib.php'); /** * Table log class for displaying logs. * @@ -403,7 +404,7 @@ public function get_cm_sql() { * @param bool $useinitialsbar do you want to use the initials bar. */ public function query_db($pagesize, $useinitialsbar = true) { - global $DB; + global $DB, $USER; $joins = array(); $params = array(); @@ -438,14 +439,35 @@ public function query_db($pagesize, $useinitialsbar = true) { } // Getting all members of a group. - if ($groupid and empty($this->filterparams->userid)) { - if ($gusers = groups_get_members($groupid)) { - $gusers = array_keys($gusers); - $joins[] = 'userid IN (' . implode(',', $gusers) . ')'; + if (empty($this->filterparams->userid)) { + if ($groupid) { + if ($gusers = groups_get_members($groupid)) { + $gusers = array_keys($gusers); + $joins[] = 'userid IN (' . implode(',', $gusers) . ')'; + } else { + $joins[] = 'userid = 0'; // No users in groups, so we want something that will always be false. + } } else { - $joins[] = 'userid = 0'; // No users in groups, so we want something that will always be false. + // No group selected and we are not filtering by user, so we want all users that are visible to the current user. + // If we are in a course, then let's check what logs we can see. + $course = get_course($this->filterparams->courseid); + $groupmode = groups_get_course_groupmode($course); + $context = context_course::instance($this->filterparams->courseid); + $userid = 0; + if ($groupmode == SEPARATEGROUPS && !has_capability('moodle/site:accessallgroups', $context)) { + $userid = $USER->id; + } + $cgroups = groups_get_all_groups($this->filterparams->courseid, $userid); + $cgroups = array_keys($cgroups); + if ($groupmode != SEPARATEGROUPS || has_capability('moodle/site:accessallgroups', $context)) { + $cgroups[] = USERSWITHOUTGROUP; + } + // If that's the case, limit the users to be in the groups only, defined by the filter. + [$groupmembersql, $groupmemberparams] = groups_get_members_ids_sql($cgroups, $context); + $joins[] = "userid IN ($groupmembersql)"; + $params = array_merge($params, $groupmemberparams); } - } else if (!empty($this->filterparams->userid)) { + } else { $joins[] = "userid = :userid"; $params['userid'] = $this->filterparams->userid; } diff --git a/report/log/tests/renderable_test.php b/report/log/tests/renderable_test.php index b3a25d70e1663..5dd39cee40687 100644 --- a/report/log/tests/renderable_test.php +++ b/report/log/tests/renderable_test.php @@ -380,4 +380,61 @@ public function test_get_group_list($courseindex, $username, $expectedcount): vo $groups = $renderable->get_group_list(); $this->assertCount($expectedcount, $groups); } + + /** + * Test table_log + * + * @param int $courseindex + * @param string $username + * @param array $expectedusers + * @param string|null $groupname + * @covers \report_log_renderable::get_user_list + * @dataProvider get_user_visibility_list_provider + * @return void + */ + public function test_get_table_logs(int $courseindex, string $username, array $expectedusers, ?string $groupname = null): void { + global $DB, $PAGE; + $this->preventResetByRollback(); // This is to ensure that we can actually trigger event and record them in the log store. + // Configure log store. + set_config('enabled_stores', 'logstore_standard', 'tool_log'); + $manager = get_log_manager(true); + $DB->delete_records('logstore_standard_log'); + + foreach ($this->courses as $course) { + foreach ($this->users as $user) { + $eventdata = [ + 'context' => context_course::instance($course->id), + 'userid' => $user->id, + ]; + $event = \core\event\course_viewed::create($eventdata); + $event->trigger(); + } + } + $stores = $manager->get_readers(); + $store = $stores['logstore_standard']; + // Build the report. + $currentuser = $this->users[$username]; + $this->setUser($currentuser->id); + $groupid = 0; + if ($groupname) { + $groupid = $this->groupsbycourse[$courseindex][$groupname]->id; + } + $PAGE->set_url('/report/log/index.php?id=' . $this->courses[$courseindex]->id); + $renderable = new \report_log_renderable("", (int) $this->courses[$courseindex]->id, 0, 0, '', $groupid); + $renderable->setup_table(); + $table = $renderable->tablelog; + $store->flush(); + $table->query_db(100); + $usernames = []; + foreach ($table->rawdata as $event) { + if (get_class($event) !== \core\event\course_viewed::class) { + continue; + } + $user = core_user::get_user($event->userid, '*', MUST_EXIST); + $usernames[] = $user->username; + } + sort($expectedusers); + sort($usernames); + $this->assertEquals($expectedusers, $usernames); + } } From a12a4b3a1432e83a0dc3c6dd4c08affbad9d7f68 Mon Sep 17 00:00:00 2001 From: Laurent David Date: Mon, 22 Jan 2024 16:45:38 +0100 Subject: [PATCH 11/22] MDL-80565 report_loglive: Do not show logs from other groups * Prevent users from seeing logs from other groups if groupmode is in SEPARATEGROUP --- report/loglive/classes/table_log.php | 19 +- report/loglive/tests/table_log_test.php | 317 ++++++++++++++++++++++++ 2 files changed, 335 insertions(+), 1 deletion(-) create mode 100644 report/loglive/tests/table_log_test.php diff --git a/report/loglive/classes/table_log.php b/report/loglive/classes/table_log.php index 19ca5e1743cc7..296849f5d07a8 100644 --- a/report/loglive/classes/table_log.php +++ b/report/loglive/classes/table_log.php @@ -298,7 +298,7 @@ protected function action_link(moodle_url $url, $text, $name = 'popup') { * @param bool $useinitialsbar do you want to use the initials bar. */ public function query_db($pagesize, $useinitialsbar = true) { - + global $USER; $joins = array(); $params = array(); @@ -306,6 +306,23 @@ public function query_db($pagesize, $useinitialsbar = true) { if (!empty($this->filterparams->courseid)) { $joins[] = "courseid = :courseid"; $params['courseid'] = $this->filterparams->courseid; + // If we are in a course, then let's check what logs we can see. + $course = get_course($this->filterparams->courseid); + $groupmode = groups_get_course_groupmode($course); + $context = context_course::instance($this->filterparams->courseid); + $userid = 0; + if ($groupmode == SEPARATEGROUPS && !has_capability('moodle/site:accessallgroups', $context)) { + $userid = $USER->id; + } + $cgroups = groups_get_all_groups($this->filterparams->courseid, $userid); + $cgroups = array_keys($cgroups); + if ($groupmode != SEPARATEGROUPS || has_capability('moodle/site:accessallgroups', $context) || empty($cgroups)) { + $cgroups[] = USERSWITHOUTGROUP; + } + // If that's the case, limit the users to be in the groups only, defined by the filter. + [$groupmembersql, $groupmemberparams] = groups_get_members_ids_sql($cgroups, $context); + $joins[] = "userid IN ($groupmembersql)"; + $params = array_merge($params, $groupmemberparams); } if (!empty($this->filterparams->date)) { diff --git a/report/loglive/tests/table_log_test.php b/report/loglive/tests/table_log_test.php new file mode 100644 index 0000000000000..f411eb36a48c9 --- /dev/null +++ b/report/loglive/tests/table_log_test.php @@ -0,0 +1,317 @@ +. +namespace report_loglive; + +use advanced_testcase; +use context_course; +use core_user; + +/** + * Tests for table log and groups. + * + * @package report_loglive + * @copyright 2024 onwards Laurent David + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later. + */ +class table_log_test extends advanced_testcase { + /** + * @var int The course with separate groups. + */ + const COURSE_SEPARATE_GROUP = 0; + /** + * @var int The course with separate groups. + */ + const COURSE_VISIBLE_GROUP = 1; + /** + * @var int The course with separate groups. + */ + const COURSE_NO_GROUP = 2; + /** + * @var array The setup of users. + */ + const SETUP_USER_DEFS = [ + // Make student2 also member of group1. + 'student' => [ + 'student0' => ['group0'], + 'student1' => ['group1'], + 'student2' => ['group0', 'group1'], + 'student3' => [], + ], + // Make teacher2 also member of group1. + 'teacher' => [ + 'teacher0' => ['group0'], + 'teacher1' => ['group1'], + 'teacher2' => ['group0', 'group1'], + 'teacher3' => [], + ], + // Make editingteacher also member of group1. + 'editingteacher' => [ + 'editingteacher0' => ['group0'], + 'editingteacher1' => ['group1'], + 'editingteacher2' => ['group0', 'group1'], + ], + ]; + /** + * @var array|\stdClass all users indexed by username. + */ + private $users = []; + /** + * @var array The groups by courses (array of array). + */ + private $groupsbycourse = []; + /** + * @var array The courses. + */ + private $courses; + + /** + * Data provider for test_get_table_logs. + * + * @return array + */ + public static function get_report_logs_provider(): array { + return [ + 'separategroups: student 0' => [ + self::COURSE_SEPARATE_GROUP, + 'student0', + // All users in group 0. + [ + 'student0', 'student2', + 'teacher0', 'teacher2', + 'editingteacher0', 'editingteacher2', + ], + ], + 'separategroups: student 1' => [ + self::COURSE_SEPARATE_GROUP, + 'student1', + // All users in group1. + [ + 'student1', 'student2', + 'teacher1', 'teacher2', + 'editingteacher1', 'editingteacher2', + ], + ], + 'separategroups: student 2' => [ + self::COURSE_SEPARATE_GROUP, + 'student2', + // All users in group0 and group1. + [ + 'student0', 'student1', 'student2', + 'teacher0', 'teacher1', 'teacher2', + 'editingteacher0', 'editingteacher1', 'editingteacher2', + ], + ], + 'separategroups: student3' => [ + self::COURSE_SEPARATE_GROUP, + 'student3', + // Student 3 is not in any group so should only see user without a group. + [ + 'student3', + 'teacher3', + ], + ], + 'separategroups: editing teacher 0' => [ + self::COURSE_SEPARATE_GROUP, + 'editingteacher0', + // All users including student 3 as we can see all users (event the one not in a group). + [ + 'student0', 'student1', 'student2', 'student3', + 'teacher0', 'teacher1', 'teacher2', 'teacher3', + 'editingteacher0', 'editingteacher1', 'editingteacher2', + ], + ], + 'separategroups: teacher 0' => [ + self::COURSE_SEPARATE_GROUP, + 'teacher0', + // All users in group 0. + [ + 'student0', 'student2', + 'teacher0', 'teacher2', + 'editingteacher0', 'editingteacher2', + ], + ], + 'separategroups: teacher 2' => [ + self::COURSE_SEPARATE_GROUP, + 'teacher2', + // All users in group0 and group1. + [ + 'student0', 'student1', 'student2', + 'teacher0', 'teacher1', 'teacher2', + 'editingteacher0', 'editingteacher1', 'editingteacher2', + ], + ], + 'separategroups: teacher 3' => [ + self::COURSE_SEPARATE_GROUP, + 'teacher3', + // Teacher 3 is not in any group so should only see user without a group. + [ + 'student3', + 'teacher3', + ], + ], + 'visiblegroup: editing teacher' => [ + self::COURSE_VISIBLE_GROUP, + 'editingteacher0', + // All users. + [ + 'student0', 'student1', 'student2', 'student3', + 'teacher0', 'teacher1', 'teacher2', 'teacher3', + 'editingteacher0', 'editingteacher1', 'editingteacher2', + ], + ], + 'visiblegroup: student' => [ + self::COURSE_VISIBLE_GROUP, + 'student0', + // All users. + [ + 'student0', 'student1', 'student2', 'student3', + 'teacher0', 'teacher1', 'teacher2', 'teacher3', + 'editingteacher0', 'editingteacher1', 'editingteacher2', + ], + ], + 'nogroup: teacher 0' => [ + self::COURSE_VISIBLE_GROUP, + 'teacher2', + // All users. + [ + 'student0', 'student1', 'student2', 'student3', + 'teacher0', 'teacher1', 'teacher2', 'teacher3', + 'editingteacher0', 'editingteacher1', 'editingteacher2', + ], + ], + 'nogroup: editing teacher 0' => [ + self::COURSE_VISIBLE_GROUP, + 'editingteacher0', + // All users. + [ + 'student0', 'student1', 'student2', 'student3', + 'teacher0', 'teacher1', 'teacher2', 'teacher3', + 'editingteacher0', 'editingteacher1', 'editingteacher2', + ], + ], + 'nogroup: student' => [ + self::COURSE_VISIBLE_GROUP, + 'student0', + // All users. + [ + 'student0', 'student1', 'student2', 'student3', + 'teacher0', 'teacher1', 'teacher2', 'teacher3', + 'editingteacher0', 'editingteacher1', 'editingteacher2', + ], + ], + ]; + } + + /** + * Set up a course with two groups, three students being each in one of the groups, + * two teachers each in either group while the second teacher is also member of the other group. + * + * @return void + * @throws \coding_exception + */ + public function setUp(): void { + global $DB; + $this->resetAfterTest(); + $this->preventResetByRollback(); // This is to ensure that we can actually trigger event and record them in the log store. + $this->courses[self::COURSE_SEPARATE_GROUP] = $this->getDataGenerator()->create_course(['groupmode' => SEPARATEGROUPS]); + $this->courses[self::COURSE_VISIBLE_GROUP] = $this->getDataGenerator()->create_course(['groupmode' => VISIBLEGROUPS]); + $this->courses[self::COURSE_NO_GROUP] = $this->getDataGenerator()->create_course(); + + foreach ($this->courses as $coursetype => $course) { + if ($coursetype == self::COURSE_NO_GROUP) { + continue; + } + $this->groupsbycourse[$coursetype] = []; + $this->groupsbycourse[$coursetype]['group0'] = + $this->getDataGenerator()->create_group(['courseid' => $course->id, 'name' => 'group0']); + $this->groupsbycourse[$coursetype]['group1'] = + $this->getDataGenerator()->create_group(['courseid' => $course->id, 'name' => 'group1']); + } + + foreach (self::SETUP_USER_DEFS as $role => $userdefs) { + foreach ($userdefs as $username => $groups) { + $user = $this->getDataGenerator()->create_user( + [ + 'username' => $username, + 'firstname' => "FN{$role}{$username}", + 'lastname' => "LN{$role}{$username}", + ]); + foreach ($this->courses as $coursetype => $course) { + $this->getDataGenerator()->enrol_user($user->id, $course->id, $role); + foreach ($groups as $groupname) { + if ($coursetype == self::COURSE_NO_GROUP) { + continue; + } + $this->getDataGenerator()->create_group_member([ + 'groupid' => $this->groupsbycourse[$coursetype][$groupname]->id, + 'userid' => $user->id, + ]); + } + } + $this->users[$username] = $user; + } + } + // Configure log store. + set_config('enabled_stores', 'logstore_standard', 'tool_log'); + get_log_manager(true); + $DB->delete_records('logstore_standard_log'); + + foreach ($this->courses as $course) { + foreach ($this->users as $user) { + $eventdata = [ + 'context' => context_course::instance($course->id), + 'userid' => $user->id, + ]; + $event = \core\event\course_viewed::create($eventdata); + $event->trigger(); + } + } + } + + /** + * Test table_log + * + * @param int $courseindex + * @param string $username + * @param array $expectedusers + * @covers \report_log_renderable::get_user_list + * @dataProvider get_report_logs_provider + * @return void + */ + public function test_get_table_logs(int $courseindex, string $username, array $expectedusers): void { + $manager = get_log_manager(); + $stores = $manager->get_readers(); + $store = $stores['logstore_standard']; + // Build the report. + $url = new \moodle_url("/report/loglive/index.php"); + $renderable = new \report_loglive_renderable('logstore_standard', $this->courses[$courseindex], $url); + $table = $renderable->get_table(); + $currentuser = $this->users[$username]; + $this->setUser($currentuser->id); + $store->flush(); + $table->query_db(100); + $filteredevents = array_filter($table->rawdata, fn($event) => get_class($event) === \core\event\course_viewed::class); + $usernames = array_map( + function($event) { + $user = core_user::get_user($event->userid, '*', MUST_EXIST); + return $user->username; + }, + $filteredevents); + sort($expectedusers); + sort($usernames); + $this->assertEquals($expectedusers, $usernames); + } +} From e2de6e45845293076746559fb1bea4b3ac7c7cbd Mon Sep 17 00:00:00 2001 From: Paul Holden Date: Mon, 11 Mar 2024 16:56:32 +0000 Subject: [PATCH 12/22] MDL-81190 mod_assign: correct context argument to format string. --- mod/assign/classes/output/renderer.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mod/assign/classes/output/renderer.php b/mod/assign/classes/output/renderer.php index 8a326117baf9e..7e78350f1b467 100644 --- a/mod/assign/classes/output/renderer.php +++ b/mod/assign/classes/output/renderer.php @@ -478,7 +478,7 @@ public function render_assign_submission_status_compact(\assign_submission_statu if ($status->teamsubmissionenabled) { $group = $status->submissiongroup; if ($group) { - $team = format_string($group->name, false, $status->context); + $team = format_string($group->name, false, ['context' => $status->context]); } else if ($status->preventsubmissionnotingroup) { if (count($status->usergroups) == 0) { $team = '' . get_string('noteam', 'assign') . ''; From a840d06652c9727f9209bf7815d6560332ae04f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?I=C3=B1igo=20Zendegi?= Date: Tue, 27 Feb 2024 15:01:38 +0100 Subject: [PATCH 13/22] MDL-80204 lang: Polysemic lang strings to & from AMOS BEGIN CPY [to,moodle],[todate,moodle] MOV [to,moodle],[torecipient,moodle] CPY [from,moodle],[fromdate,moodle] MOV [from,moodle],[fromsender,moodle] AMOS END --- admin/tool/licensemanager/classes/form/edit_license.php | 2 +- course/moodleform_mod.php | 4 ++-- lang/en/deprecated.txt | 2 ++ lang/en/moodle.php | 8 ++++++-- mod/chat/gui_basic/index.php | 2 +- .../report/summary/classes/form/dates_filter_form.php | 6 ++++-- report/outline/classes/filter_form.php | 4 ++-- reportbuilder/classes/local/filters/number.php | 2 +- 8 files changed, 19 insertions(+), 11 deletions(-) diff --git a/admin/tool/licensemanager/classes/form/edit_license.php b/admin/tool/licensemanager/classes/form/edit_license.php index 624ce80fa01f1..c9f2f1e977e16 100644 --- a/admin/tool/licensemanager/classes/form/edit_license.php +++ b/admin/tool/licensemanager/classes/form/edit_license.php @@ -94,7 +94,7 @@ public function definition() { $mform->addHelpButton('source', 'source', 'tool_licensemanager'); $mform->addRule('source', get_string('sourcerequirederror', 'tool_licensemanager'), 'required'); - $mform->addElement('date_selector', 'version', get_string('version', 'tool_licensemanager'), get_string('from')); + $mform->addElement('date_selector', 'version', get_string('version', 'tool_licensemanager')); $mform->addHelpButton('version', 'version', 'tool_licensemanager'); $this->add_action_buttons(); diff --git a/course/moodleform_mod.php b/course/moodleform_mod.php index bb7483809f5ca..3357140484a15 100644 --- a/course/moodleform_mod.php +++ b/course/moodleform_mod.php @@ -751,11 +751,11 @@ protected function add_rating_settings($mform, int $itemnumber) { $mform->addElement('checkbox', 'ratingtime', get_string('ratingtime', 'rating')); $mform->hideIf('ratingtime', $assessedfieldname, 'eq', 0); - $mform->addElement('date_time_selector', 'assesstimestart', get_string('from')); + $mform->addElement('date_time_selector', 'assesstimestart', get_string('fromdate')); $mform->hideIf('assesstimestart', $assessedfieldname, 'eq', 0); $mform->hideIf('assesstimestart', 'ratingtime'); - $mform->addElement('date_time_selector', 'assesstimefinish', get_string('to')); + $mform->addElement('date_time_selector', 'assesstimefinish', get_string('todate')); $mform->hideIf('assesstimefinish', $assessedfieldname, 'eq', 0); $mform->hideIf('assesstimefinish', 'ratingtime'); diff --git a/lang/en/deprecated.txt b/lang/en/deprecated.txt index 1f52a300942b5..79d40637f4ca5 100644 --- a/lang/en/deprecated.txt +++ b/lang/en/deprecated.txt @@ -140,3 +140,5 @@ updatingain,core summaryof,core gradeitemadvanced,core_grades gradeitemadvanced_help,core_grades +to,core +from,core diff --git a/lang/en/moodle.php b/lang/en/moodle.php index 3d7e61430217e..21bfc94f34d81 100644 --- a/lang/en/moodle.php +++ b/lang/en/moodle.php @@ -942,7 +942,8 @@ $string['formattext'] = 'Moodle auto-format'; $string['forumpreferences'] = 'Forum preferences'; $string['framesetinfo'] = 'This frameset document contains:'; -$string['from'] = 'From'; +$string['fromdate'] = 'From'; +$string['fromsender'] = 'From'; $string['frontpagecategorycombo'] = 'Combo list'; $string['frontpagecategorynames'] = 'List of categories'; $string['frontpagecourselist'] = 'List of courses'; @@ -2256,7 +2257,8 @@ $string['timecreated'] = 'Time created'; $string['timecreatedcourse'] = 'Course time created'; $string['timezone'] = 'Timezone'; -$string['to'] = 'To'; +$string['todate'] = 'To'; +$string['torecipient'] = 'To'; $string['tocreatenewaccount'] = 'Skip to create new account'; $string['tocontent'] = 'To item "{$a}"'; $string['today'] = 'Today'; @@ -2504,3 +2506,5 @@ $string['updatinga'] = 'Updating: {$a}'; $string['updatingain'] = 'Updating {$a->what} in {$a->in}'; $string['summaryof'] = 'Summary of {$a}'; +$string['from'] = 'From'; +$string['to'] = 'To'; diff --git a/mod/chat/gui_basic/index.php b/mod/chat/gui_basic/index.php index 6468b01405444..310b3fabb6fa1 100644 --- a/mod/chat/gui_basic/index.php +++ b/mod/chat/gui_basic/index.php @@ -196,7 +196,7 @@ } echo ''; echo ' - + '; diff --git a/mod/forum/report/summary/classes/form/dates_filter_form.php b/mod/forum/report/summary/classes/form/dates_filter_form.php index 5d9f32e3db51e..6535d232b8e0a 100644 --- a/mod/forum/report/summary/classes/form/dates_filter_form.php +++ b/mod/forum/report/summary/classes/form/dates_filter_form.php @@ -45,9 +45,11 @@ public function definition() { ]; // From date field. - $this->_form->addElement('date_selector', 'filterdatefrompopover', get_string('from'), ['optional' => true], $attributes); + $this->_form->addElement('date_selector', 'filterdatefrompopover', + get_string('fromdate'), ['optional' => true], $attributes); // To date field. - $this->_form->addElement('date_selector', 'filterdatetopopover', get_string('to'), ['optional' => true], $attributes); + $this->_form->addElement('date_selector', 'filterdatetopopover', + get_string('todate'), ['optional' => true], $attributes); } } diff --git a/report/outline/classes/filter_form.php b/report/outline/classes/filter_form.php index 4e7dd0caf4800..bc3f490b02a00 100644 --- a/report/outline/classes/filter_form.php +++ b/report/outline/classes/filter_form.php @@ -46,8 +46,8 @@ protected function definition() { $mform->addElement('header', 'filterheader', get_string('filter')); $opts = ['optional' => true]; - $mform->addElement('date_selector', 'filterstartdate', get_string('from'), $opts); - $mform->addElement('date_selector', 'filterenddate', get_string('to'), $opts); + $mform->addElement('date_selector', 'filterstartdate', get_string('fromdate'), $opts); + $mform->addElement('date_selector', 'filterenddate', get_string('todate'), $opts); $mform->setExpanded('filterheader', false); diff --git a/reportbuilder/classes/local/filters/number.php b/reportbuilder/classes/local/filters/number.php index 3534832626416..e4ce83500a8eb 100644 --- a/reportbuilder/classes/local/filters/number.php +++ b/reportbuilder/classes/local/filters/number.php @@ -96,7 +96,7 @@ public function setup_form(\MoodleQuickForm $mform): void { $mform->hideIf($this->name . '_value1', $this->name . '_operator', 'in', [self::ANY_VALUE, self::IS_NOT_EMPTY, self::IS_EMPTY]); - $objs['text2'] = $mform->createElement('text', $this->name . '_value2', get_string('to'), ['size' => 3]); + $objs['text2'] = $mform->createElement('text', $this->name . '_value2', get_string('torecipient'), ['size' => 3]); $mform->setType($this->name . '_value2', PARAM_INT); $mform->setDefault($this->name . '_value2', 0); $mform->hideIf($this->name . '_value2', $this->name . '_operator', 'noteq', self::RANGE); From 54a51e838206c228b6021e349a55065b6f783297 Mon Sep 17 00:00:00 2001 From: Paul Holden Date: Thu, 9 Nov 2023 16:59:21 +0000 Subject: [PATCH 14/22] MDL-80063 roles: implement roles datasource for custom reporting. Comprised of existing context, role & user entities to provide all report data. --- .../reportbuilder/datasource/roles.php | 124 +++++++++ .../reportbuilder/local/entities/role.php | 34 ++- .../local/entities/role_assignment.php | 143 +++++++++++ .../reportbuilder/datasource/roles_test.php | 239 ++++++++++++++++++ lang/en/role.php | 2 + 5 files changed, 541 insertions(+), 1 deletion(-) create mode 100644 admin/roles/classes/reportbuilder/datasource/roles.php create mode 100644 admin/roles/classes/reportbuilder/local/entities/role_assignment.php create mode 100644 admin/roles/tests/reportbuilder/datasource/roles_test.php diff --git a/admin/roles/classes/reportbuilder/datasource/roles.php b/admin/roles/classes/reportbuilder/datasource/roles.php new file mode 100644 index 0000000000000..a20082272ec48 --- /dev/null +++ b/admin/roles/classes/reportbuilder/datasource/roles.php @@ -0,0 +1,124 @@ +. + +declare(strict_types=1); + +namespace core_role\reportbuilder\datasource; + +use core\reportbuilder\local\entities\context; +use core_reportbuilder\datasource; +use core_reportbuilder\local\entities\user; +use core_role\reportbuilder\local\entities\{role, role_assignment}; + +/** + * Roles datasource + * + * @package core_role + * @copyright 2024 Paul Holden + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class roles extends datasource { + + /** + * Return user friendly name of the report source + * + * @return string + */ + public static function get_name(): string { + return get_string('roles', 'core_role'); + } + + /** + * Initialise report + */ + protected function initialise(): void { + $contextentity = new context(); + $contextalias = $contextentity->get_table_alias('context'); + + $roleentity = new role(); + $rolealias = $roleentity->get_table_alias('role'); + + // Role table. + $this->add_entity($roleentity->set_table_alias('context', $contextalias)); + $this->set_main_table('role', $rolealias); + + // Join role assignments. + $roleassignmententity = new role_assignment(); + $roleassignmentalias = $roleassignmententity->get_table_alias('role_assignments'); + $this->add_entity($roleassignmententity); + $this->add_join("JOIN {role_assignments} {$roleassignmentalias} ON {$roleassignmentalias}.roleid = {$rolealias}.id"); + + // Join context. + $this->add_entity($contextentity); + $this->add_join("LEFT JOIN {context} {$contextalias} ON {$contextalias}.id = {$roleassignmentalias}.contextid"); + + // Join user. + $userentity = new user(); + $useralias = $userentity->get_table_alias('user'); + $this->add_entity($userentity + ->add_join("LEFT JOIN {user} {$useralias} ON {$useralias}.id = {$roleassignmentalias}.userid")); + + $this->add_all_from_entities(); + } + + /** + * Return the columns that will be added to the report upon creation + * + * @return string[] + */ + public function get_default_columns(): array { + return [ + 'context:link', + 'role:originalname', + 'user:fullnamewithlink', + ]; + } + + /** + * Return the column sorting that will be added to the report upon creation + * + * @return int[] + */ + public function get_default_column_sorting(): array { + return [ + 'context:link' => SORT_ASC, + 'role:originalname' => SORT_ASC, + 'user:fullnamewithlink' => SORT_ASC, + ]; + } + + /** + * Return the filters that will be added to the report upon creation + * + * @return string[] + */ + public function get_default_filters(): array { + return [ + 'context:level', + 'role:name', + 'user:fullname', + ]; + } + + /** + * Return the conditions that will be added to the report upon creation + * + * @return string[] + */ + public function get_default_conditions(): array { + return []; + } +} diff --git a/admin/roles/classes/reportbuilder/local/entities/role.php b/admin/roles/classes/reportbuilder/local/entities/role.php index e83516c6f709b..bd613573c1ad3 100644 --- a/admin/roles/classes/reportbuilder/local/entities/role.php +++ b/admin/roles/classes/reportbuilder/local/entities/role.php @@ -14,6 +14,8 @@ // You should have received a copy of the GNU General Public License // along with Moodle. If not, see . +declare(strict_types=1); + namespace core_role\reportbuilder\local\entities; use context; @@ -97,7 +99,13 @@ protected function get_all_columns(): array { ->set_type(column::TYPE_TEXT) ->add_fields("{$rolealias}.name, {$rolealias}.shortname, {$rolealias}.id, {$contextalias}.id AS contextid") ->add_fields(context_helper::get_preload_record_columns_sql($contextalias)) - ->set_is_sortable(true, ["CASE WHEN {$rolealias}.name = '' THEN {$rolealias}.shortname ELSE {$rolealias}.name END"]) + // The sorting is on name, unless empty (determined by single space - thanks Oracle) then we use shortname. + ->set_is_sortable(true, [ + "CASE WHEN " . $DB->sql_concat("{$rolealias}.name", "' '") . " = ' ' + THEN {$rolealias}.shortname + ELSE {$rolealias}.name + END", + ]) ->set_callback(static function($name, stdClass $role): string { if ($name === null) { return ''; @@ -109,6 +117,30 @@ protected function get_all_columns(): array { return role_get_name($role, $context, ROLENAME_BOTH); }); + // Original name column. + $columns[] = (new column( + 'originalname', + new lang_string('roleoriginalname', 'core_role'), + $this->get_entity_name() + )) + ->add_joins($this->get_joins()) + ->set_type(column::TYPE_TEXT) + ->add_fields("{$rolealias}.name, {$rolealias}.shortname") + // The sorting is on name, unless empty (determined by single space - thanks Oracle) then we use shortname. + ->set_is_sortable(true, [ + "CASE WHEN " . $DB->sql_concat("{$rolealias}.name", "' '") . " = ' ' + THEN {$rolealias}.shortname + ELSE {$rolealias}.name + END", + ]) + ->set_callback(static function($name, stdClass $role): string { + if ($name === null) { + return ''; + } + + return role_get_name($role, null, ROLENAME_ORIGINAL); + }); + // Short name column. $columns[] = (new column( 'shortname', diff --git a/admin/roles/classes/reportbuilder/local/entities/role_assignment.php b/admin/roles/classes/reportbuilder/local/entities/role_assignment.php new file mode 100644 index 0000000000000..fda44a9c73a5b --- /dev/null +++ b/admin/roles/classes/reportbuilder/local/entities/role_assignment.php @@ -0,0 +1,143 @@ +. + +declare(strict_types=1); + +namespace core_role\reportbuilder\local\entities; + +use lang_string; +use core_reportbuilder\local\entities\base; +use core_reportbuilder\local\filters\date; +use core_reportbuilder\local\helpers\format; +use core_reportbuilder\local\report\{column, filter}; + +/** + * Role assignment entity + * + * @package core_role + * @copyright 2024 Paul Holden + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class role_assignment extends base { + + /** + * Database tables that this entity uses + * + * @return string[] + */ + protected function get_default_tables(): array { + return [ + 'role_assignments', + ]; + } + + /** + * The default title for this entity + * + * @return lang_string + */ + protected function get_default_entity_title(): lang_string { + return new lang_string('roleassignment', 'core_role'); + } + + /** + * 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 { + $raalias = $this->get_table_alias('role_assignments'); + + // Time modified column. + $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_field("{$raalias}.timemodified") + ->set_is_sortable(true) + ->set_callback([format::class, 'userdate']); + + // Component column. + $columns[] = (new column( + 'component', + new lang_string('plugin'), + $this->get_entity_name() + )) + ->add_joins($this->get_joins()) + ->set_type(column::TYPE_TEXT) + ->add_field("{$raalias}.component") + ->set_is_sortable(true); + + // Item ID column. + $columns[] = (new column( + 'itemid', + new lang_string('pluginitemid'), + $this->get_entity_name() + )) + ->add_joins($this->get_joins()) + ->set_type(column::TYPE_INTEGER) + ->add_field("{$raalias}.itemid") + ->set_is_sortable(true); + + return $columns; + } + + /** + * Return list of all available filters + * + * @return filter[] + */ + protected function get_all_filters(): array { + $raalias = $this->get_table_alias('role_assignments'); + + // Time modified filter. + $filters[] = (new filter( + date::class, + 'timemodified', + new lang_string('timemodified', 'core_reportbuilder'), + $this->get_entity_name(), + "{$raalias}.timemodified" + )) + ->add_joins($this->get_joins()); + + return $filters; + } +} diff --git a/admin/roles/tests/reportbuilder/datasource/roles_test.php b/admin/roles/tests/reportbuilder/datasource/roles_test.php new file mode 100644 index 0000000000000..4f4258daaa4e5 --- /dev/null +++ b/admin/roles/tests/reportbuilder/datasource/roles_test.php @@ -0,0 +1,239 @@ +. + +declare(strict_types=1); + +namespace core_role\reportbuilder\datasource; + +use core\context\course; +use core_reportbuilder_generator; +use core_reportbuilder_testcase; +use core_reportbuilder\local\filters\{date, select, text}; + +defined('MOODLE_INTERNAL') || die(); + +global $CFG; +require_once("{$CFG->dirroot}/reportbuilder/tests/helpers.php"); + +/** + * Unit tests for roles datasource + * + * @package core_role + * @covers \core_role\reportbuilder\datasource\roles; + * @copyright 2024 Paul Holden + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +final class roles_test extends core_reportbuilder_testcase { + + /** + * Test default datasource + */ + public function test_datasource_default(): void { + $this->resetAfterTest(); + + $course = $this->getDataGenerator()->create_course(); + $context = course::instance($course->id); + + $studentone = $this->getDataGenerator()->create_and_enrol($course, 'student', ['firstname' => 'Zoe']); + $studenttwo = $this->getDataGenerator()->create_and_enrol($course, 'student', ['firstname' => 'Amy']); + $manager = $this->getDataGenerator()->create_and_enrol($course, 'manager'); + + /** @var core_reportbuilder_generator $generator */ + $generator = $this->getDataGenerator()->get_plugin_generator('core_reportbuilder'); + $report = $generator->create_report(['name' => 'Roles', 'source' => roles::class, 'default' => 1]); + + $content = $this->get_custom_report_content($report->get('id')); + $this->assertCount(3, $content); + + // Default columns are context link, original role name and user link. Sorted by each. + [$contextlink, $rolename, $userlink] = array_values($content[0]); + $this->assertStringContainsString($context->get_context_name(), $contextlink); + $this->assertEquals('Manager', $rolename); + $this->assertStringContainsString(fullname($manager), $userlink); + + [$contextlink, $rolename, $userlink] = array_values($content[1]); + $this->assertStringContainsString($context->get_context_name(), $contextlink); + $this->assertEquals('Student', $rolename); + $this->assertStringContainsString(fullname($studenttwo), $userlink); + + [$contextlink, $rolename, $userlink] = array_values($content[2]); + $this->assertStringContainsString($context->get_context_name(), $contextlink); + $this->assertEquals('Student', $rolename); + $this->assertStringContainsString(fullname($studentone), $userlink); + } + + /** + * Test datasource columns that aren't added by default + */ + public function test_datasource_non_default_columns(): void { + global $DB; + + $this->resetAfterTest(); + + $course = $this->getDataGenerator()->create_course(); + $context = course::instance($course->id); + + // Create an alias for our role. + $roleid = $DB->get_field('role', 'id', ['shortname' => 'manager']); + $DB->insert_record('role_names', (object) [ + 'contextid' => $context->id, + 'roleid' => $roleid, + 'name' => 'Moocher', + ]); + + $manager = $this->getDataGenerator()->create_user(); + $this->getDataGenerator()->enrol_user($manager->id, $course->id, $roleid); + + /** @var core_reportbuilder_generator $generator */ + $generator = $this->getDataGenerator()->get_plugin_generator('core_reportbuilder'); + $report = $generator->create_report(['name' => 'Roles', 'source' => roles::class, 'default' => 0]); + + // Role. + $generator->create_column(['reportid' => $report->get('id'), 'uniqueidentifier' => 'role:name']); + $generator->create_column(['reportid' => $report->get('id'), 'uniqueidentifier' => 'role:shortname']); + $generator->create_column(['reportid' => $report->get('id'), 'uniqueidentifier' => 'role:description']); + + // Role assignment. + $generator->create_column(['reportid' => $report->get('id'), 'uniqueidentifier' => 'role_assignment:timemodified']); + $generator->create_column(['reportid' => $report->get('id'), 'uniqueidentifier' => 'role_assignment:component']); + $generator->create_column(['reportid' => $report->get('id'), 'uniqueidentifier' => 'role_assignment:itemid']); + + $content = $this->get_custom_report_content($report->get('id')); + $this->assertCount(1, $content); + + [$rolename, $roleshortname, $roledescription, $timemodified, $component, $itemid] = array_values($content[0]); + + // Role. + $this->assertEquals('Moocher (Manager)', $rolename); + $this->assertEquals('manager', $roleshortname); + $this->assertEquals('Managers can access courses and modify them, but usually do not participate in them.', + $roledescription); + + // Role assignment. + $this->assertNotEmpty($timemodified); + $this->assertEquals('', $component); + $this->assertEquals(0, $itemid); + } + + /** + * Data provider for {@see test_datasource_filters} + * + * @return array[] + */ + public static function datasource_filters_provider(): array { + global $DB; + + return [ + // Role. + 'Filter role name' => ['role:name', [ + 'role:name_operator' => select::EQUAL_TO, + 'role:name_value' => $DB->get_field('role', 'id', ['shortname' => 'student']), + ], true], + 'Filter role name (no match)' => ['role:name', [ + 'role:name_operator' => select::EQUAL_TO, + 'role:name_value' => -1, + ], false], + + // Role assignment. + 'Filter role assignment time modified' => ['role_assignment:timemodified', [ + 'role_assignment:timemodified_operator' => date::DATE_RANGE, + 'role_assignment:timemodified_from' => 1622502000, + ], true], + 'Filter role assignment time modified (no match)' => ['role_assignment:timemodified', [ + 'role_assignment:timemodified_operator' => date::DATE_RANGE, + 'role_assignment:timemodified_to' => 1622502000, + ], false], + + // Context. + 'Filter context level' => ['context:level', [ + 'context:level_operator' => select::EQUAL_TO, + 'context:level_value' => CONTEXT_COURSE, + ], true], + 'Filter context level (no match)' => ['context:level', [ + 'context:level_operator' => select::EQUAL_TO, + 'context:level_value' => CONTEXT_COURSECAT, + ], false], + + // User. + 'Filter user firstname' => ['user:firstname', [ + 'user:firstname_operator' => text::IS_EQUAL_TO, + 'user:firstname_value' => 'Zoe', + ], true], + 'Filter user firstname (no match)' => ['user:firstname', [ + 'user:firstname_operator' => text::IS_EQUAL_TO, + 'user:firstname_value' => 'Amy', + ], false], + ]; + } + + /** + * Test datasource filters + * + * @param string $filtername + * @param array $filtervalues + * @param bool $expectmatch + * + * @dataProvider datasource_filters_provider + */ + public function test_datasource_filters( + string $filtername, + array $filtervalues, + bool $expectmatch, + ): void { + $this->resetAfterTest(); + + $course = $this->getDataGenerator()->create_course(); + $this->getDataGenerator()->create_and_enrol($course, 'student', ['firstname' => 'Zoe']); + + /** @var core_reportbuilder_generator $generator */ + $generator = $this->getDataGenerator()->get_plugin_generator('core_reportbuilder'); + + // Create report containing single column, and given filter. + $report = $generator->create_report(['name' => 'Roles', 'source' => roles::class, 'default' => 0]); + $generator->create_column(['reportid' => $report->get('id'), 'uniqueidentifier' => 'role:shortname']); + + // 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('student', 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(); + + $course = $this->getDataGenerator()->create_course(); + $this->getDataGenerator()->create_and_enrol($course); + + $this->datasource_stress_test_columns(roles::class); + $this->datasource_stress_test_columns_aggregation(roles::class); + $this->datasource_stress_test_conditions(roles::class, 'role:shortname'); + } +} diff --git a/lang/en/role.php b/lang/en/role.php index af51354d812ec..64f5c8e7fce48 100644 --- a/lang/en/role.php +++ b/lang/en/role.php @@ -404,6 +404,7 @@ $string['roleallowheader'] = 'Allow role:'; $string['roleallowinfo'] = 'Select a role to be added to the list of allowed roles in context "{$a->context}", capability "{$a->cap}":'; $string['role:assign'] = 'Assign roles to users'; +$string['roleassignment'] = 'Role assignment'; $string['roleassignments'] = 'Role assignments'; $string['roledefinitions'] = 'Role definitions'; $string['rolefullname'] = 'Role name'; @@ -411,6 +412,7 @@ $string['role:manage'] = 'Create and manage roles'; $string['role:override'] = 'Override permissions for others'; $string['role:review'] = 'Review permissions for others'; +$string['roleoriginalname'] = 'Original name'; $string['roleprohibitheader'] = 'Prohibit role'; $string['roleprohibitinfo'] = 'Select a role to be added to the list of prohibited roles in context "{$a->context}", capability "{$a->cap}":'; $string['rolerisks'] = 'Role risks'; From c464c70298cd83234527a8b2ec995be7341184da Mon Sep 17 00:00:00 2001 From: AMOS bot Date: Wed, 13 Mar 2024 00:11:14 +0000 Subject: [PATCH 15/22] Automatically generated installer lang files --- install/lang/bg/error.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/install/lang/bg/error.php b/install/lang/bg/error.php index 9cdf32d1c894f..5e2f402b1fe93 100644 --- a/install/lang/bg/error.php +++ b/install/lang/bg/error.php @@ -29,6 +29,10 @@ defined('MOODLE_INTERNAL') || die(); +$string['cannotcreatedboninstall'] = '

Не може да се създаде базата данни.

+

Посочената база данни не съществува и даденият потребител няма разрешение да създаде базата данни.

+

Администраторът на сайта трябва да провери конфигурацията на базата данни.

'; +$string['cannotcreatelangdir'] = 'Не може да се създаде езикова директория'; $string['cannotcreatetempdir'] = 'Не може да създаде временна директория'; $string['cannotfindcomponent'] = 'Не можа да намери компонент'; $string['remotedownloaderror'] = 'Изтеглянето на компонента към вашия сървър пропадна, проверете настройките на proxy, препоръчително е PHP разширението cURL.

Вие трябва ръчно да изтеглите файла {$a->url}, да го копирате в директория {$a->dest} на вашия сървър и да го разархивирате там.'; From f8c3d1c36c9c7f41d282df8a529c5b288acac1f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikel=20Mart=C3=ADn?= Date: Wed, 13 Mar 2024 08:58:57 +0100 Subject: [PATCH 16/22] MDL-81213 theme_boost: Fix .form-control width MDL-75670 generated a regression in some forms where .form-control elements where now being displayed as full width (Bootstrap default). - Override Bootstrap default width: 100% for .form-control elements - Move related .custom-select same override from core.scss to forms.scss --- theme/boost/scss/moodle/core.scss | 5 ----- theme/boost/scss/moodle/forms.scss | 11 +++++++++-- theme/boost/style/moodle.css | 14 +++++++------- theme/classic/style/moodle.css | 14 +++++++------- 4 files changed, 23 insertions(+), 21 deletions(-) diff --git a/theme/boost/scss/moodle/core.scss b/theme/boost/scss/moodle/core.scss index 5ec22406c892e..80e5a3635baaa 100644 --- a/theme/boost/scss/moodle/core.scss +++ b/theme/boost/scss/moodle/core.scss @@ -2461,11 +2461,6 @@ input[disabled] { cursor: not-allowed; } -.custom-select { - width: auto; - max-width: 100%; -} - .fade.in { opacity: 1; } diff --git a/theme/boost/scss/moodle/forms.scss b/theme/boost/scss/moodle/forms.scss index 57a91eb4c821f..457ba46e4d25a 100644 --- a/theme/boost/scss/moodle/forms.scss +++ b/theme/boost/scss/moodle/forms.scss @@ -23,8 +23,9 @@ } .mform .d-flex { - .form-control, - .custom-select { + // Override the default bootstrap form-control width. + .form-control { + width: auto; max-width: 100%; } textarea.form-control { @@ -40,6 +41,12 @@ } } +// Override the default bootstrap custom-select width. +.custom-select { + width: auto; + max-width: 100%; +} + #jump-to-activity.custom-select { width: 100%; } diff --git a/theme/boost/style/moodle.css b/theme/boost/style/moodle.css index 316487b30620f..73581e4362180 100644 --- a/theme/boost/style/moodle.css +++ b/theme/boost/style/moodle.css @@ -25352,11 +25352,6 @@ input[disabled] { cursor: not-allowed; } -.custom-select { - width: auto; - max-width: 100%; -} - .fade.in { opacity: 1; } @@ -32851,8 +32846,8 @@ body.path-question-type .mform fieldset.hidden { margin-bottom: 4px; } -.mform .d-flex .form-control, -.mform .d-flex .custom-select { +.mform .d-flex .form-control { + width: auto; max-width: 100%; } .mform .d-flex textarea.form-control { @@ -32867,6 +32862,11 @@ body.path-question-type .mform fieldset.hidden { margin-right: 0; } +.custom-select { + width: auto; + max-width: 100%; +} + #jump-to-activity.custom-select { width: 100%; } diff --git a/theme/classic/style/moodle.css b/theme/classic/style/moodle.css index d28086a753b9b..18cd471340e37 100644 --- a/theme/classic/style/moodle.css +++ b/theme/classic/style/moodle.css @@ -25352,11 +25352,6 @@ input[disabled] { cursor: not-allowed; } -.custom-select { - width: auto; - max-width: 100%; -} - .fade.in { opacity: 1; } @@ -32851,8 +32846,8 @@ body.path-question-type .mform fieldset.hidden { margin-bottom: 4px; } -.mform .d-flex .form-control, -.mform .d-flex .custom-select { +.mform .d-flex .form-control { + width: auto; max-width: 100%; } .mform .d-flex textarea.form-control { @@ -32867,6 +32862,11 @@ body.path-question-type .mform fieldset.hidden { margin-right: 0; } +.custom-select { + width: auto; + max-width: 100%; +} + #jump-to-activity.custom-select { width: 100%; } From 955d8693bff5fa9b4fe3b9af74ea653933bee365 Mon Sep 17 00:00:00 2001 From: Angelia Dela Cruz Date: Wed, 10 Jan 2024 15:28:18 +0800 Subject: [PATCH 17/22] MDL-80479 mod_quiz: Behat to set quiz opening and closing time --- .../behat/quiz_activity_availability.feature | 136 ++++++++++++++++++ 1 file changed, 136 insertions(+) create mode 100644 mod/quiz/tests/behat/quiz_activity_availability.feature diff --git a/mod/quiz/tests/behat/quiz_activity_availability.feature b/mod/quiz/tests/behat/quiz_activity_availability.feature new file mode 100644 index 0000000000000..20b1c3b832174 --- /dev/null +++ b/mod/quiz/tests/behat/quiz_activity_availability.feature @@ -0,0 +1,136 @@ +@mod @mod_quiz +Feature: Quiz availability can be set + In order to see quiz availability + As a teacher + I need to be able to set quiz opening and closing times + + Background: + Given the following "users" exist: + | username | firstname | lastname | email | + | teacher1 | Teacher | One | teacher1@example.com | + | student1 | Student | One | student1@example.com | + And the following "courses" exist: + | fullname | shortname | + | Course 1 | C1 | + And the following "course enrolments" exist: + | user | course | role | + | teacher1 | C1 | editingteacher | + | student1 | C1 | student | + And the following "question categories" exist: + | contextlevel | reference | name | + | Course | C1 | Test questions | + And the following "questions" exist: + | questioncategory | qtype | name | questiontext | + | Test questions | truefalse | TF1 | First question | + + Scenario Outline: Set quiz opening time while closing time is disabled + Given the following "activities" exist: + | activity | course | name | timeopen | + | quiz | C1 | Quiz 1 | | + And quiz "Quiz 1" contains the following questions: + | question | page | maxmark | + | TF1 | 1 | 2 | + When I am on the "Quiz 1" "quiz activity" page logged in as student1 + # Confirm display as student depending on case. + Then I should see ":" + And I should see "%A, %d %B %Y, %I:%M##" + And I should not see "Close:" + And I see "This quiz is currently not available." + And "Attempt quiz" "button" exist + + Examples: + | opentext | timeopen | attemptvisibility | quizavailability | + # Case 1 - open is set to future date, close is disabled. + | Opens | ##tomorrow## | should not | should | + # Case 4 - open is set to past date, close is disabled. + | Opened | ##yesterday## | should | should not | + + Scenario Outline: Set quiz closing time while opening time is disabled + Given the following "activities" exist: + | activity | course | name | timeclose | + | quiz | C1 | Quiz 1 | | + And quiz "Quiz 1" contains the following questions: + | question | page | maxmark | + | TF1 | 1 | 2 | + When I am on the "Quiz 1" "quiz activity" page logged in as student1 + # Confirm display as student depending on case. + Then I should see ":" + And I should see "%A, %d %B %Y, %I:%M##" + And I see "This quiz is currently not available." + And "Attempt quiz" "button" exist + + Examples: + | closetext | timeclose | attemptvisibility | quizavailability | + # Case 2 - open is disabled, close is set to past date. + | Closed | ##yesterday## | should not | should not | + # Case 5 - open is disabled, close is set to future date. + | Closes | ##tomorrow## | should | should not | + + Scenario Outline: Set quiz opening and closing times + Given the following "activities" exist: + | activity | course | name | timeopen | timeclose | + | quiz | C1 | Quiz 1 | | | + And quiz "Quiz 1" contains the following questions: + | question | page | maxmark | + | TF1 | 1 | 2 | + When I am on the "Quiz 1" "quiz activity" page logged in as student1 + # Confirm display as student depending on case. + Then I should see ":" + And I should see "%A, %d %B %Y, %I:%M##" + And I should see ":" + And I should see "%A, %d %B %Y, %I:%M##" + And I see "This quiz is currently not available." + And "Attempt quiz" "button" exist + + Examples: + | opentext | timeopen | closetext | timeclose | attemptvisibility | quizavailability | + # Case 6 - open and close are set to past date. + | Opened | ##3 days ago## | Closed | ##yesterday## | should not | should not | + # Case 7 - open is set to past date, close is set to future date. + | Opened | ##yesterday## | Closes | ##tomorrow## | should | should not | + # Case 8 - open and close are set to future date + | Opens | ##tomorrow## | Closes | ##+2 days## | should not | should | + + Scenario: Quiz time open and time close are disabled + # Case 3 - both open and close are disabled. + Given the following "activities" exist: + | activity | course | name | + | quiz | C1 | Quiz 1 | + And quiz "Quiz 1" contains the following questions: + | question | page | maxmark | + | TF1 | 1 | 2 | + When I am on the "Quiz 1" "quiz activity" page logged in as student1 + Then I should not see "Opens" + And I should not see "Opened" + And I should not see "Closes" + And I should not see "Closed" + And I should not see "This quiz is currently not available." + And "Attempt quiz" "button" should exist + + @javascript + Scenario Outline: Timer is displayed when quiz closes in less than an hour + Given the following "activities" exist: + | activity | course | name | + | quiz | C1 | Quiz 1 | + And quiz "Quiz 1" contains the following questions: + | question | page | + | TF1 | 1 | + Given I am on the "Quiz 1" "quiz activity editing" page logged in as teacher1 + And I set the following fields to these values: + | timeclose[enabled] | 1 | + | Close the quiz | | + And I press "Save and display" + When I press "Preview quiz" + # Confirm timer visibility for teacher + Then I see "Time left" + # Confirm timer visibility for student + And I am on the "Quiz 1" "quiz activity" page logged in as student1 + And I press "Attempt quiz" + And I see "Time left" + + Examples: + | closedate | timervisibility | + # Case 1 - closedate is < 1hr, the timer is visible + | ##now +10 minutes## | should | + # Case 2 - closedate is > 1hr, the timer is not visible + | ##now +2 hours## | should not | From f0dbf61e47abe28de588c9da564794da9a1431d6 Mon Sep 17 00:00:00 2001 From: Simey Lameze Date: Thu, 14 Mar 2024 10:17:09 +0800 Subject: [PATCH 18/22] MDL-80479 behat: improve new test for quiz timer --- .../behat/quiz_activity_availability.feature | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/mod/quiz/tests/behat/quiz_activity_availability.feature b/mod/quiz/tests/behat/quiz_activity_availability.feature index 20b1c3b832174..bb07940006de7 100644 --- a/mod/quiz/tests/behat/quiz_activity_availability.feature +++ b/mod/quiz/tests/behat/quiz_activity_availability.feature @@ -42,7 +42,7 @@ Feature: Quiz availability can be set | opentext | timeopen | attemptvisibility | quizavailability | # Case 1 - open is set to future date, close is disabled. | Opens | ##tomorrow## | should not | should | - # Case 4 - open is set to past date, close is disabled. + # Case 4 - open is set to past date, close is disabled. | Opened | ##yesterday## | should | should not | Scenario Outline: Set quiz closing time while opening time is disabled @@ -110,22 +110,18 @@ Feature: Quiz availability can be set @javascript Scenario Outline: Timer is displayed when quiz closes in less than an hour Given the following "activities" exist: - | activity | course | name | - | quiz | C1 | Quiz 1 | + | activity | course | name | timeclose | + | quiz | C1 | Quiz 1 | | And quiz "Quiz 1" contains the following questions: | question | page | | TF1 | 1 | - Given I am on the "Quiz 1" "quiz activity editing" page logged in as teacher1 - And I set the following fields to these values: - | timeclose[enabled] | 1 | - | Close the quiz | | - And I press "Save and display" + And I am on the "Quiz 1" "quiz activity" page logged in as "teacher1" When I press "Preview quiz" # Confirm timer visibility for teacher Then I see "Time left" - # Confirm timer visibility for student - And I am on the "Quiz 1" "quiz activity" page logged in as student1 + And I am on the "Quiz 1" "quiz activity" page logged in as "student1" And I press "Attempt quiz" + # Confirm timer visibility for student And I see "Time left" Examples: From 6a19d3289bb401e849173ba8d256dc1cac1fb883 Mon Sep 17 00:00:00 2001 From: Huong Nguyen Date: Tue, 5 Mar 2024 11:40:07 +0700 Subject: [PATCH 19/22] MDL-80167 admin: Add environment check for Oracle database --- admin/environment.xml | 8 ++++++++ lang/en/admin.php | 1 + lib/tests/upgradelib_test.php | 32 ++++++++++++++++++++++++++++++++ lib/upgradelib.php | 23 +++++++++++++++++++++++ 4 files changed, 64 insertions(+) diff --git a/admin/environment.xml b/admin/environment.xml index 4925aafb1c80d..e031c7fc270bc 100644 --- a/admin/environment.xml +++ b/admin/environment.xml @@ -3923,6 +3923,8 @@ + + @@ -4113,6 +4115,8 @@ + + @@ -4305,6 +4309,8 @@ + + @@ -4496,6 +4502,8 @@ + + diff --git a/lang/en/admin.php b/lang/en/admin.php index 624f5604f053e..d6d570dac53a6 100644 --- a/lang/en/admin.php +++ b/lang/en/admin.php @@ -986,6 +986,7 @@ $string['opensslrequired'] = 'The OpenSSL PHP extension is now required by Moodle to provide stronger cryptographic services.'; $string['opentowebcrawlers'] = 'Open to search engines'; $string['optionalmaintenancemessage'] = 'Optional maintenance message'; +$string['oracledatabaseinuse'] = 'We are changing Oracle DB support in Moodle LMS. Moodle version 4.5 will be the last version that supports Oracle as a database architecture. Further information can be found here'; $string['order1'] = 'First'; $string['order2'] = 'Second'; $string['order3'] = 'Third'; diff --git a/lib/tests/upgradelib_test.php b/lib/tests/upgradelib_test.php index edf91f2c211c4..4b56c8507c187 100644 --- a/lib/tests/upgradelib_test.php +++ b/lib/tests/upgradelib_test.php @@ -1390,6 +1390,38 @@ public function test_check_mod_assignment_is_used(): void { } } + /** + * Test the check_oracle_usage check when the Moodle instance is not using Oracle as a database architecture. + * + * @covers ::check_oracle_usage + */ + public function test_check_oracle_usage_is_not_used(): void { + global $CFG; + + $this->resetAfterTest(); + $CFG->dbtype = 'pgsql'; + + $result = new environment_results('custom_checks'); + $this->assertNull(check_oracle_usage($result)); + } + + /** + * Test the check_oracle_usage check when the Moodle instance is using Oracle as a database architecture. + * + * @covers ::check_oracle_usage + */ + public function test_check_oracle_usage_is_used(): void { + global $CFG; + + $this->resetAfterTest(); + $CFG->dbtype = 'oci'; + + $result = new environment_results('custom_checks'); + $this->assertInstanceOf(environment_results::class, check_oracle_usage($result)); + $this->assertEquals('oracle_database_usage', $result->getInfo()); + $this->assertFalse($result->getStatus()); + } + /** * Data provider of usermenu items. * diff --git a/lib/upgradelib.php b/lib/upgradelib.php index d71f317d5d083..c2e1b17ac3e0d 100644 --- a/lib/upgradelib.php +++ b/lib/upgradelib.php @@ -2875,3 +2875,26 @@ function check_mod_assignment(environment_results $result): ?environment_results return null; } + +/** + * Check whether the Oracle database is currently being used and warn if so. + * + * The Oracle database support will be removed in a future version (4.5) as it is no longer supported by PHP. + * + * @param environment_results $result object to update, if relevant + * @return environment_results|null updated results or null if the current database is not Oracle. + * + * @see https://tracker.moodle.org/browse/MDL-80166 for further information. + */ +function check_oracle_usage(environment_results $result): ?environment_results { + global $CFG; + + // Checking database type. + if ($CFG->dbtype === 'oci') { + $result->setInfo('oracle_database_usage'); + $result->setFeedbackStr('oracledatabaseinuse'); + return $result; + } + + return null; +} From 699a0e9328a4d17c9633fb28ea1241a1e4124766 Mon Sep 17 00:00:00 2001 From: Huong Nguyen Date: Fri, 15 Mar 2024 08:45:42 +0700 Subject: [PATCH 20/22] MDL-80167 core: Skip Oracle for Oracle environment in unit tests --- lib/tests/environment_test.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/lib/tests/environment_test.php b/lib/tests/environment_test.php index 1d9295a1aabea..ebadd7b8b0ac4 100644 --- a/lib/tests/environment_test.php +++ b/lib/tests/environment_test.php @@ -85,6 +85,10 @@ public function test_environment($result) { // If we're on a 32-bit system, skip 64-bit check. 32-bit PHP has PHP_INT_SIZE set to 4. $this->markTestSkipped('64-bit check is not necessary for unit testing.'); } + if ($result->info === 'oracle_database_usage') { + // If we're on a system that uses the Oracle database, skip the Oracle check. + $this->markTestSkipped('Oracle database check is not necessary for unit testing.'); + } } $info = "{$result->part}:{$result->info}"; $this->assertTrue($result->getStatus(), "Problem detected in environment ($info), fix all warnings and errors!"); From 78b73c45ac96442f07d3a3f416beda06e9ab1cb0 Mon Sep 17 00:00:00 2001 From: Jake Dallimore Date: Fri, 15 Mar 2024 12:17:07 +0800 Subject: [PATCH 21/22] MDL-81180 tool_mobile: fix typo in callback namespace --- admin/tool/mobile/db/hooks.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/admin/tool/mobile/db/hooks.php b/admin/tool/mobile/db/hooks.php index a0ec1affd9921..111aa4517e2b2 100644 --- a/admin/tool/mobile/db/hooks.php +++ b/admin/tool/mobile/db/hooks.php @@ -27,7 +27,7 @@ $callbacks = [ [ 'hook' => \core\hook\output\before_standard_head_html_generation::class, - 'callback' => [\tool_mobile\local\hooks\output\before_standard_head_html_generation::class, 'callback'], + 'callback' => [\tool_mobile\local\hook\output\before_standard_head_html_generation::class, 'callback'], 'priority' => 0, ], ]; From 757be30c3903bed55ad4a3607346b23510741745 Mon Sep 17 00:00:00 2001 From: Huong Nguyen Date: Fri, 15 Mar 2024 21:48:16 +0700 Subject: [PATCH 22/22] weekly release 4.4dev+ --- version.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/version.php b/version.php index 524a65dd4a0c2..37da845aa4094 100644 --- a/version.php +++ b/version.php @@ -29,9 +29,9 @@ defined('MOODLE_INTERNAL') || die(); -$version = 2024031300.00; // YYYYMMDD = weekly release date of this DEV branch. +$version = 2024031500.00; // YYYYMMDD = weekly release date of this DEV branch. // RR = release increments - 00 in DEV branches. // .XX = incremental changes. -$release = '4.4dev+ (Build: 20240313)'; // Human-friendly version name +$release = '4.4dev+ (Build: 20240315)'; // Human-friendly version name $branch = '404'; // This version's branch. $maturity = MATURITY_ALPHA; // This version's maturity level.
' . get_string('from') . '' . get_string('fromsender') . ' ' . get_string('message', 'message') . ' ' . get_string('time') . '