Merge branch 'MDL-63725_master' of git://github.com/markn86/moodle
authorJun Pataleta <jun@moodle.com>
Thu, 1 Nov 2018 08:11:06 +0000 (16:11 +0800)
committerJun Pataleta <jun@moodle.com>
Thu, 1 Nov 2018 08:11:06 +0000 (16:11 +0800)
158 files changed:
admin/tool/cohortroles/classes/privacy/provider.php
admin/tool/cohortroles/tests/privacy_test.php
admin/tool/dataprivacy/classes/metadata_registry.php
admin/tool/dataprivacy/lang/en/tool_dataprivacy.php
admin/tool/dataprivacy/lib.php
admin/tool/dataprivacy/settings.php
admin/tool/dataprivacy/templates/component_status.mustache
admin/tool/dataprivacy/templates/data_registry_compliance.mustache
admin/tool/oauth2/classes/form/issuer.php
admin/tool/oauth2/issuers.php
admin/tool/oauth2/lang/en/tool_oauth2.php
badges/classes/privacy/provider.php
badges/tests/privacy_test.php
blocks/classes/privacy/provider.php
blocks/myoverview/amd/build/main.min.js
blocks/myoverview/amd/build/selectors.min.js [new file with mode: 0644]
blocks/myoverview/amd/build/view.min.js
blocks/myoverview/amd/build/view_nav.min.js
blocks/myoverview/amd/src/main.js
blocks/myoverview/amd/src/selectors.js [new file with mode: 0644]
blocks/myoverview/amd/src/view.js
blocks/myoverview/amd/src/view_nav.js
blocks/myoverview/classes/privacy/provider.php
blocks/myoverview/lang/en/block_myoverview.php
blocks/myoverview/lib.php
blocks/myoverview/templates/course-action-menu.mustache
blocks/myoverview/templates/courses-view.mustache
blocks/myoverview/templates/nav-grouping-selector.mustache
blocks/myoverview/templates/view-cards.mustache
blocks/myoverview/templates/view-list.mustache
blocks/myoverview/templates/view-summary.mustache
blocks/myoverview/tests/behat/block_myoverview_dashboard.feature
blocks/myoverview/tests/behat/block_myoverview_favourite.feature
blocks/myoverview/tests/behat/block_myoverview_hidden.feature [new file with mode: 0644]
blocks/myoverview/tests/privacy_test.php
blocks/recentlyaccesseditems/amd/build/main.min.js [new file with mode: 0644]
blocks/recentlyaccesseditems/amd/build/repository.min.js [new file with mode: 0644]
blocks/recentlyaccesseditems/amd/src/main.js [new file with mode: 0644]
blocks/recentlyaccesseditems/amd/src/repository.js [new file with mode: 0644]
blocks/recentlyaccesseditems/block_recentlyaccesseditems.php [new file with mode: 0644]
blocks/recentlyaccesseditems/classes/external.php [new file with mode: 0644]
blocks/recentlyaccesseditems/classes/external/recentlyaccesseditems_item_exporter.php [new file with mode: 0644]
blocks/recentlyaccesseditems/classes/helper.php [new file with mode: 0644]
blocks/recentlyaccesseditems/classes/observer.php [new file with mode: 0644]
blocks/recentlyaccesseditems/classes/output/main.php [new file with mode: 0644]
blocks/recentlyaccesseditems/classes/output/renderer.php [new file with mode: 0644]
blocks/recentlyaccesseditems/classes/privacy/provider.php [new file with mode: 0644]
blocks/recentlyaccesseditems/db/access.php [new file with mode: 0644]
blocks/recentlyaccesseditems/db/events.php [new file with mode: 0644]
blocks/recentlyaccesseditems/db/install.xml [new file with mode: 0644]
blocks/recentlyaccesseditems/db/services.php [new file with mode: 0644]
blocks/recentlyaccesseditems/lang/en/block_recentlyaccesseditems.php [new file with mode: 0644]
blocks/recentlyaccesseditems/pix/items.svg [new file with mode: 0644]
blocks/recentlyaccesseditems/templates/main.mustache [new file with mode: 0644]
blocks/recentlyaccesseditems/templates/no-items.mustache [new file with mode: 0644]
blocks/recentlyaccesseditems/templates/placeholder-item.mustache [new file with mode: 0644]
blocks/recentlyaccesseditems/templates/recentlyaccesseditems-view.mustache [new file with mode: 0644]
blocks/recentlyaccesseditems/templates/view-cards.mustache [new file with mode: 0644]
blocks/recentlyaccesseditems/tests/behat/block_recentlyaccesseditems_dashboard.feature [new file with mode: 0644]
blocks/recentlyaccesseditems/tests/externallib_test.php [new file with mode: 0644]
blocks/recentlyaccesseditems/tests/observer_test.php [new file with mode: 0644]
blocks/recentlyaccesseditems/version.php [new file with mode: 0644]
blocks/starredcourses/amd/build/main.min.js [new file with mode: 0644]
blocks/starredcourses/amd/build/repository.min.js [new file with mode: 0644]
blocks/starredcourses/amd/src/main.js [new file with mode: 0644]
blocks/starredcourses/amd/src/repository.js [new file with mode: 0644]
blocks/starredcourses/block_starredcourses.php [new file with mode: 0644]
blocks/starredcourses/classes/external.php [new file with mode: 0644]
blocks/starredcourses/classes/output/main.php [new file with mode: 0644]
blocks/starredcourses/classes/output/renderer.php [new file with mode: 0644]
blocks/starredcourses/classes/privacy/provider.php [new file with mode: 0644]
blocks/starredcourses/db/access.php [new file with mode: 0644]
blocks/starredcourses/db/services.php [new file with mode: 0644]
blocks/starredcourses/lang/en/block_starredcourses.php [new file with mode: 0644]
blocks/starredcourses/pix/courses.svg [new file with mode: 0644]
blocks/starredcourses/templates/main.mustache [new file with mode: 0644]
blocks/starredcourses/templates/no-courses.mustache [new file with mode: 0644]
blocks/starredcourses/templates/placeholder-course.mustache [new file with mode: 0644]
blocks/starredcourses/templates/view-cards.mustache [new file with mode: 0644]
blocks/starredcourses/templates/view.mustache [new file with mode: 0644]
blocks/starredcourses/version.php [new file with mode: 0644]
blocks/tests/privacy_test.php
blog/classes/privacy/provider.php
blog/tests/privacy_test.php
calendar/classes/privacy/provider.php
calendar/tests/privacy_test.php
course/amd/build/events.min.js [new file with mode: 0644]
course/amd/src/events.js [new file with mode: 0644]
course/classes/external/course_summary_exporter.php
course/externallib.php
course/lib.php
course/templates/favouriteicon.mustache [moved from blocks/myoverview/templates/favourite-icon.mustache with 82% similarity]
course/tests/courselib_test.php
course/upgrade.txt
enrol/tests/enrollib_test.php
group/externallib.php
group/tests/externallib_test.php
lang/en/course.php
lib/amd/build/paged_content_factory.min.js
lib/amd/build/paged_content_pages.min.js
lib/amd/src/paged_content_factory.js
lib/amd/src/paged_content_pages.js
lib/classes/oauth2/api.php
lib/classes/oauth2/issuer.php
lib/classes/plugin_manager.php
lib/db/services.php
lib/editor/atto/classes/privacy/provider.php
lib/enrollib.php
lib/tests/oauth2_test.php
message/classes/api.php
message/classes/helper.php
message/externallib.php
message/index.php
message/tests/api_test.php
message/tests/externallib_test.php
message/upgrade.txt
mod/assign/feedback/comments/locallib.php
mod/assign/submission/comments/tests/privacy_test.php
mod/assign/tests/locallib_test.php
mod/scorm/classes/event/sco_launched.php
mod/scorm/tests/externallib_test.php
notes/classes/privacy/provider.php
notes/tests/privacy_test.php
privacy/classes/local/request/userlist.php
privacy/tests/provider_test.php
question/type/ddmarker/amd/build/shapes.min.js
question/type/ddmarker/amd/src/shapes.js
question/type/multianswer/edit_multianswer_form.php
question/type/multianswer/tests/behat/add.feature [new file with mode: 0644]
repository/googledocs/tests/generator/lib.php
repository/nextcloud/classes/access_controlled_link_manager.php [new file with mode: 0644]
repository/nextcloud/classes/configuration_exception.php [new file with mode: 0755]
repository/nextcloud/classes/issuer_management.php [new file with mode: 0644]
repository/nextcloud/classes/ocs_client.php [new file with mode: 0644]
repository/nextcloud/classes/privacy/provider.php [new file with mode: 0644]
repository/nextcloud/classes/request_exception.php [new file with mode: 0755]
repository/nextcloud/db/access.php [new file with mode: 0755]
repository/nextcloud/lang/en/repository_nextcloud.php [new file with mode: 0755]
repository/nextcloud/lib.php [new file with mode: 0755]
repository/nextcloud/pix/icon.svg [new file with mode: 0644]
repository/nextcloud/tests/access_controlled_link_manager_test.php [new file with mode: 0644]
repository/nextcloud/tests/fixtures/testable_access_controlled_link_manager.php [new file with mode: 0644]
repository/nextcloud/tests/generator/lib.php [new file with mode: 0755]
repository/nextcloud/tests/lib_test.php [new file with mode: 0644]
repository/nextcloud/tests/ocs_test.php [new file with mode: 0644]
repository/nextcloud/version.php [new file with mode: 0755]
repository/tests/generator_test.php
theme/boost/scss/moodle/blocks.scss
theme/boost/scss/moodle/bs4alphacompat.scss
theme/boost/style/moodle.css
theme/boost/templates/core/filemanager_fileselect.mustache
theme/boost/templates/header.mustache
theme/bootstrapbase/less/moodle/blocks.less
theme/bootstrapbase/style/moodle.css
theme/bootstrapbase/templates/block_myoverview/course-action-menu.mustache
user/action_redir.php
user/index.php
version.php

index bc38e7c..2cdfca6 100644 (file)
@@ -30,6 +30,8 @@ use core_privacy\local\request\context;
 use core_privacy\local\request\contextlist;
 use core_privacy\local\request\transform;
 use core_privacy\local\request\writer;
+use core_privacy\local\request\userlist;
+use \core_privacy\local\request\approved_userlist;
 
 defined('MOODLE_INTERNAL') || die();
 
@@ -39,7 +41,10 @@ defined('MOODLE_INTERNAL') || die();
  * @copyright  2018 Zig Tan <zig@moodle.com>
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
-class provider implements \core_privacy\local\metadata\provider, \core_privacy\local\request\plugin\provider {
+class provider implements
+        \core_privacy\local\metadata\provider,
+        \core_privacy\local\request\core_userlist_provider,
+        \core_privacy\local\request\plugin\provider {
 
     /**
      * Returns meta data about this system.
@@ -91,6 +96,35 @@ class provider implements \core_privacy\local\metadata\provider, \core_privacy\l
         return $contextlist;
     }
 
+    /**
+     * Get the list of users within a specific context.
+     *
+     * @param userlist $userlist The userlist containing the list of users who have data in this context/plugin combination.
+     */
+    public static function get_users_in_context(userlist $userlist) {
+        $context = $userlist->get_context();
+
+        // We should process user data from the system context.
+        // When we process user deletions and expiries, we always delete from the user context.
+        // As a result the cohort role assignments would be deleted, which has a knock-on effect with courses
+        // as roles may change and data may be removed earlier than it should be.
+        if (!$context instanceof \context_system) {
+            return;
+        }
+
+        $params = [
+            'contextid' => $context->id
+        ];
+
+        $sql = "SELECT tc.userid as userid
+                  FROM {tool_cohortroles} tc
+                  JOIN {cohort} c
+                       ON tc.cohortid = c.id
+                 WHERE c.contextid = :contextid";
+
+        $userlist->add_from_sql('userid', $sql, $params);
+    }
+
     /**
      * Export all user data for the specified user, in the specified contexts.
      *
@@ -172,6 +206,25 @@ class provider implements \core_privacy\local\metadata\provider, \core_privacy\l
         $DB->delete_records('tool_cohortroles', ['userid' => $userid]);
     }
 
+    /**
+     * Delete multiple users within a single context.
+     *
+     * @param approved_userlist $userlist The approved context and user information to delete information for.
+     */
+    public static function delete_data_for_users(approved_userlist $userlist) {
+        global $DB;
+
+        $context = $userlist->get_context();
+
+        // We should process user data from the system context.
+        // When we process user deletions and expiries, we always delete from the user context.
+        // As a result the cohort role assignments would be deleted, which has a knock-on effect with courses
+        // as roles may change and data may be removed earlier than it should be.
+        if ($context instanceof \context_system) {
+            $DB->delete_records_list('tool_cohortroles', 'userid', $userlist->get_userids());
+        }
+    }
+
     /**
      * Delete all user data for the specified user, in the specified contexts.
      *
index f118b74..26f99d3 100644 (file)
@@ -30,6 +30,7 @@ use \core_privacy\local\request\writer;
 use \core_privacy\local\request\approved_contextlist;
 use \tool_cohortroles\api;
 use \tool_cohortroles\privacy\provider;
+use core_privacy\local\request\approved_userlist;
 
 /**
  * Unit tests for the tool_cohortroles implementation of the privacy API.
@@ -179,16 +180,107 @@ class tool_cohortroles_privacy_testcase extends \core_privacy\tests\provider_tes
         $this->assertCount(0, $cohortroles);
     }
 
+    /**
+     * Test that only users within a course context are fetched.
+     */
+    public function test_get_users_in_context() {
+        $component = 'tool_cohortroles';
+
+        // Create a user.
+        $user = $this->getDataGenerator()->create_user();
+        $usercontext = context_user::instance($user->id);
+
+        $systemcontext = context_system::instance();
+
+        $this->setAdminUser();
+
+        $userlist = new \core_privacy\local\request\userlist($systemcontext, $component);
+        provider::get_users_in_context($userlist);
+        $this->assertCount(0, $userlist);
+
+        $nocohortroles = 3;
+        $this->setup_test_scenario_data($user->id, $nocohortroles);
+
+        // The list of users within the system context should contain user.
+        provider::get_users_in_context($userlist);
+        $this->assertCount(1, $userlist);
+        $this->assertTrue(in_array($user->id, $userlist->get_userids()));
+
+        // The list of users within the user context should be empty.
+        $userlist2 = new \core_privacy\local\request\userlist($usercontext, $component);
+        provider::get_users_in_context($userlist2);
+        $this->assertCount(0, $userlist2);
+    }
+
+    /**
+     * Test that data for users in approved userlist is deleted.
+     */
+    public function test_delete_data_for_users() {
+        $component = 'tool_cohortroles';
+
+        // Create user1.
+        $user1 = $this->getDataGenerator()->create_user();
+        // Create user2.
+        $user2 = $this->getDataGenerator()->create_user();
+        // Create user3.
+        $user3 = $this->getDataGenerator()->create_user();
+        $usercontext3 = context_user::instance($user3->id);
+
+        $systemcontext = context_system::instance();
+
+        $this->setAdminUser();
+
+        $nocohortroles = 3;
+        $this->setup_test_scenario_data($user1->id, $nocohortroles);
+        $this->setup_test_scenario_data($user2->id, $nocohortroles, 'Sausage roll 2',
+                'sausageroll2');
+        $this->setup_test_scenario_data($user3->id, $nocohortroles, 'Sausage roll 3',
+                'sausageroll3');
+
+        $userlist1 = new \core_privacy\local\request\userlist($systemcontext, $component);
+        provider::get_users_in_context($userlist1);
+        $this->assertCount(3, $userlist1);
+        $this->assertTrue(in_array($user1->id, $userlist1->get_userids()));
+        $this->assertTrue(in_array($user2->id, $userlist1->get_userids()));
+        $this->assertTrue(in_array($user3->id, $userlist1->get_userids()));
+
+        // Convert $userlist1 into an approved_contextlist.
+        $approvedlist1 = new approved_userlist($systemcontext, $component, [$user1->id, $user2->id]);
+        // Delete using delete_data_for_user.
+        provider::delete_data_for_users($approvedlist1);
+
+        // Re-fetch users in systemcontext.
+        $userlist1 = new \core_privacy\local\request\userlist($systemcontext, $component);
+        provider::get_users_in_context($userlist1);
+        // The user data of user1 and user2 in systemcontext should be deleted.
+        // The user data of user3 in systemcontext should be still present.
+        $this->assertCount(1, $userlist1);
+        $this->assertTrue(in_array($user3->id, $userlist1->get_userids()));
+
+        // Convert $userlist1 into an approved_contextlist in the user context.
+        $approvedlist2 = new approved_userlist($usercontext3, $component, $userlist1->get_userids());
+        // Delete using delete_data_for_user.
+        provider::delete_data_for_users($approvedlist2);
+        // Re-fetch users in systemcontext.
+        $userlist1 = new \core_privacy\local\request\userlist($systemcontext, $component);
+        provider::get_users_in_context($userlist1);
+        // The user data in systemcontext should not be deleted.
+        $this->assertCount(1, $userlist1);
+    }
+
     /**
      * Helper function to setup tool_cohortroles records for testing a specific user.
      *
      * @param int $userid           The ID of the user used for testing.
      * @param int $nocohortroles    The number of tool_cohortroles to create for the user.
+     * @param string $rolename      The name of the role to be created.
+     * @param string $roleshortname The short name of the role to be created.
      * @throws \core_competency\invalid_persistent_exception
      * @throws coding_exception
      */
-    protected function setup_test_scenario_data($userid, $nocohortroles) {
-        $roleid = create_role('Sausage Roll', 'sausageroll', 'mmmm');
+    protected function setup_test_scenario_data($userid, $nocohortroles, $rolename = 'Sausage Roll',
+                                                $roleshortname = 'sausageroll') {
+        $roleid = create_role($rolename, $roleshortname, 'mmmm');
 
         for ($c = 0; $c < $nocohortroles; $c++) {
             $cohort = $this->getDataGenerator()->create_cohort();
index 96c9e55..3d25904 100644 (file)
@@ -77,14 +77,21 @@ class metadata_registry {
                     $internaldata['external'] = true;
                 }
 
-                // Check if the interface is deprecated.
+                // Additional interface checks.
                 if (!$manager->is_empty_subsystem($component)) {
                     $classname = $manager->get_provider_classname_for_component($component);
                     if (class_exists($classname)) {
                         $componentclass = new $classname();
+                        // Check if the interface is deprecated.
                         if ($componentclass instanceof \core_privacy\local\deprecated) {
                             $internaldata['deprecated'] = true;
                         }
+
+                        // Check that the userlist provider is implemented.
+                        if ($componentclass instanceof \core_privacy\local\request\core_user_data_provider
+                                && !$componentclass instanceof \core_privacy\local\request\core_userlist_provider) {
+                            $internaldata['userlistnoncompliance'] = true;
+                        }
                     }
                 }
 
index 0c5ac5f..bae15aa 100644 (file)
@@ -277,6 +277,8 @@ $string['send'] = 'Send';
 $string['sensitivedatareasons'] = 'Sensitive personal data processing reasons';
 $string['sensitivedatareasons_help'] = 'Select one or more applicable reasons that exempts the prohibition of processing sensitive personal data tied to this purpose. For more information, please see  <a href="https://gdpr-info.eu/art-9-gdpr/" target="_blank">GDPR Art. 9.2</a>';
 $string['setdefaults'] = 'Set defaults';
+$string['showdataretentionsummary'] = 'Show data retention summary';
+$string['showdataretentionsummary_desc'] = 'If enabled, a link to the data retention summary is shown in the page footer and in the user profile page.';
 $string['statusapproved'] = 'Approved';
 $string['statusawaitingapproval'] = 'Awaiting approval';
 $string['statuscancelled'] = 'Cancelled';
@@ -293,6 +295,8 @@ $string['subjectscope'] = 'Subject scope';
 $string['subjectscope_help'] = 'The subject scope lists the roles which may be assigned in this context.';
 $string['summary'] = 'Registry configuration summary';
 $string['user'] = 'User';
+$string['userlistnoncompliant'] = 'Userlist provider missing';
+$string['userlistexplanation'] = 'This plugin has the base provider but should also implement the userlist provider for full support of privacy functionality.';
 $string['viewrequest'] = 'View the request';
 $string['visible'] = 'Expand all';
 $string['unexpiredrolewithretention'] = '{$a->retention} (Unexpired)';
index 276850e..8655f1c 100644 (file)
@@ -87,10 +87,19 @@ function tool_dataprivacy_myprofile_navigation(tree $tree, $user, $iscurrentuser
         }
     }
 
-    $summaryurl = new moodle_url('/admin/tool/dataprivacy/summary.php');
-    $summarynode = new core_user\output\myprofile\node('privacyandpolicies', 'retentionsummary',
+    // A returned 0 means that the setting was set and disabled, false means that there is no value for the provided setting.
+    $showsummary = get_config('tool_dataprivacy', 'showdataretentionsummary');
+    if ($showsummary === false) {
+        // This means that no value is stored in db. We use the default value in this case.
+        $showsummary = true;
+    }
+
+    if ($showsummary) {
+        $summaryurl = new moodle_url('/admin/tool/dataprivacy/summary.php');
+        $summarynode = new core_user\output\myprofile\node('privacyandpolicies', 'retentionsummary',
             get_string('dataretentionsummary', 'tool_dataprivacy'), null, $summaryurl);
-    $category->add_node($summarynode);
+        $category->add_node($summarynode);
+    }
 
     // Add the Privacy category to the tree if it's not empty and it doesn't exist.
     $nodes = $category->nodes;
@@ -110,11 +119,20 @@ function tool_dataprivacy_myprofile_navigation(tree $tree, $user, $iscurrentuser
  * @return string HTML footer content
  */
 function tool_dataprivacy_standard_footer_html() {
+    $output = '';
 
-    $url = new moodle_url('/admin/tool/dataprivacy/summary.php');
-    $output = html_writer::link($url, get_string('dataretentionsummary', 'tool_dataprivacy'));
-    $output = html_writer::div($output, 'summaryfooter');
+    // A returned 0 means that the setting was set and disabled, false means that there is no value for the provided setting.
+    $showsummary = get_config('tool_dataprivacy', 'showdataretentionsummary');
+    if ($showsummary === false) {
+        // This means that no value is stored in db. We use the default value in this case.
+        $showsummary = true;
+    }
 
+    if ($showsummary) {
+        $url = new moodle_url('/admin/tool/dataprivacy/summary.php');
+        $output = html_writer::link($url, get_string('dataretentionsummary', 'tool_dataprivacy'));
+        $output = html_writer::div($output, 'tool_dataprivacy');
+    }
     return $output;
 }
 
index d3abf47..a434384 100644 (file)
@@ -66,6 +66,12 @@ if ($hassiteconfig) {
                 new lang_string('requireallenddatesforuserdeletion', 'tool_dataprivacy'),
                 new lang_string('requireallenddatesforuserdeletion_desc', 'tool_dataprivacy'),
                 1));
+
+        // Whether the data retention summary should be shown in the page footer and in the user profile page.
+        $privacysettings->add(new admin_setting_configcheckbox('tool_dataprivacy/showdataretentionsummary',
+            new lang_string('showdataretentionsummary', 'tool_dataprivacy'),
+            new lang_string('showdataretentionsummary_desc', 'tool_dataprivacy'),
+            1));
     }
 }
 
index 3f7b691..e55a30b 100644 (file)
@@ -64,6 +64,9 @@
         {{#deprecated}}
             <span class="badge badge-pill badge-warning">{{#str}}deprecated, tool_dataprivacy{{/str}}</span>
         {{/deprecated}}
+        {{#userlistnoncompliance}}
+            <span class="badge badge-pill badge-warning">{{#str}}userlistnoncompliant, tool_dataprivacy{{/str}}</span>
+        {{/userlistnoncompliance}}
     </div>
 
     {{#compliant}}
index c4a9c1b..bbca4ff 100644 (file)
@@ -47,6 +47,8 @@
         <dd>{{#str}}externalexplanation, tool_dataprivacy{{/str}}</dd>
         <dt><span class="badge badge-pill badge-warning">{{#str}}deprecated, tool_dataprivacy{{/str}}</span></dt>
         <dd>{{#str}}deprecatedexplanation, tool_dataprivacy{{/str}}</dd>
+        <dt><span class="badge badge-pill badge-warning">{{#str}}userlistnoncompliant, tool_dataprivacy{{/str}}</span></dt>
+        <dd>{{#str}}userlistexplanation, tool_dataprivacy{{/str}}</dd>
     </dl>
     <hr />
     <div class="clearfix"><a class="tool_dataprivacy-expand-all pull-right" href="#" data-visibility-state='visible'>{{#str}}visible, tool_dataprivacy{{/str}}</a></div>
index 23874bb..5efe02b 100644 (file)
@@ -41,7 +41,38 @@ class issuer extends persistent {
     protected static $persistentclass = 'core\\oauth2\\issuer';
 
     /** @var array $fieldstoremove */
-    protected static $fieldstoremove = array('submitbutton', 'action');
+    protected static $fieldstoremove = array('type', 'submitbutton', 'action');
+
+    /** @var string $type */
+    protected $type;
+
+    /**
+     * Constructor.
+     *
+     * The 'persistent' has to be passed as custom data when 'editing'.
+     * If a standard issuer is created the type can be passed as custom data, which alters the form according to the
+     * type.
+     *
+     * Note that in order for your persistent to be reloaded after form submission you should
+     * either override the URL to include the ID to your resource, or add the ID to the form
+     * fields.
+     *
+     * @param mixed $action
+     * @param mixed $customdata
+     * @param string $method
+     * @param string $target
+     * @param mixed $attributes
+     * @param bool $editable
+     * @param array $ajaxformdata
+     */
+    public function __construct($action = null, $customdata = null, $method = 'post', $target = '', $attributes = null,
+                                $editable = true, array $ajaxformdata = null) {
+        // The type variable defines, if we are in the creation process of a standard issuer.
+        if (array_key_exists('type', $customdata)) {
+            $this->type = $customdata['type'];
+        }
+        parent::__construct($action, $customdata, $method, $target, $attributes, $editable, $ajaxformdata);
+    }
 
     /**
      * Define the form - called by parent constructor
@@ -108,6 +139,9 @@ class issuer extends persistent {
         $mform->addElement('text', 'baseurl', get_string('issuerbaseurl', 'tool_oauth2'));
         $mform->addRule('baseurl', get_string('maximumchars', '', 1024), 'maxlength', 1024, 'client');
         $mform->addHelpButton('baseurl', 'issuerbaseurl', 'tool_oauth2');
+        if ($this->type && $this->type == 'nextcloud') {
+            $mform->addRule('baseurl', null, 'required', null, 'client');
+        }
 
         // Allowed Domains.
         $mform->addElement('text', 'alloweddomains', get_string('issueralloweddomains', 'tool_oauth2'));
@@ -130,8 +164,16 @@ class issuer extends persistent {
         $mform->addElement('hidden', 'sortorder');
         $mform->setType('sortorder', PARAM_INT);
 
-        $mform->addElement('hidden', 'action', 'edit');
-        $mform->setType('action', PARAM_ALPHA);
+        if ($this->type) {
+            $mform->addElement('hidden', 'action', 'savetemplate');
+            $mform->setType('action', PARAM_ALPHA);
+
+            $mform->addElement('hidden', 'type', $this->_customdata['type']);
+            $mform->setType('type', PARAM_ALPHA);
+        } else {
+            $mform->addElement('hidden', 'action', 'edit');
+            $mform->setType('action', PARAM_ALPHA);
+        }
 
         $mform->addElement('hidden', 'enabled', $issuer->get('enabled'));
         $mform->setType('enabled', PARAM_BOOL);
index 5bb4c4f..87fb17d 100644 (file)
@@ -86,15 +86,36 @@ if ($mform && $mform->is_cancelled()) {
         $mform->display();
         echo $OUTPUT->footer();
     }
+} else if ($action == 'savetemplate') {
+
+    $type = required_param('type', PARAM_ALPHA);
+    $mform = new \tool_oauth2\form\issuer(null, ['persistent' => $issuer, 'type' => $type]);
+    if ($mform->is_cancelled()) {
+        redirect(new moodle_url('/admin/tool/oauth2/issuers.php'));
+    }
+    if ($mform->is_submitted() && $data = $mform->get_data()) {
+        $issuer = new core\oauth2\issuer(0, $data);
+        $issuer->create();
+        $issuer = core\oauth2\api::create_endpoints_for_standard_issuer($type, $issuer);
+        redirect($PAGE->url, get_string('changessaved'), null, \core\output\notification::NOTIFY_SUCCESS);
+    } else {
+        echo $OUTPUT->header();
+        $mform->display();
+        echo $OUTPUT->footer();
+    }
+
 } else if ($action == 'edittemplate') {
 
     $type = required_param('type', PARAM_ALPHA);
     $docs = required_param('docslink', PARAM_ALPHAEXT);
     require_sesskey();
-    $issuer = core\oauth2\api::create_standard_issuer($type);
-    $params = ['action' => 'edit', 'id' => $issuer->get('id'), 'docslink' => $docs];
-    $editurl = new moodle_url('/admin/tool/oauth2/issuers.php', $params);
-    redirect($editurl, get_string('changessaved'), null, \core\output\notification::NOTIFY_SUCCESS);
+    $issuer = core\oauth2\api::init_standard_issuer($type);
+    $mform = new \tool_oauth2\form\issuer(null, ['persistent' => $issuer, 'type' => $type]);
+
+    echo $OUTPUT->header();
+    $mform->display();
+    echo $OUTPUT->footer();
+
 } else if ($action == 'enable') {
 
     require_sesskey();
@@ -171,6 +192,11 @@ if ($mform && $mform->is_cancelled()) {
     $addurl = new moodle_url('/admin/tool/oauth2/issuers.php', $params);
     echo $renderer->single_button($addurl, get_string('createnewfacebookissuer', 'tool_oauth2'));
     $addurl = new moodle_url('/admin/tool/oauth2/issuers.php', ['action' => 'edit']);
+    $docs = 'admin/tool/oauth2/issuers/nextcloud';
+    $params = ['action' => 'edittemplate', 'type' => 'nextcloud', 'sesskey' => sesskey(), 'docslink' => $docs];
+    $addurl = new moodle_url('/admin/tool/oauth2/issuers.php', $params);
+    echo $renderer->single_button($addurl, get_string('createnewnextcloudissuer', 'tool_oauth2'));
+    $addurl = new moodle_url('/admin/tool/oauth2/issuers.php', ['action' => 'edit']);
     echo $renderer->single_button($addurl, get_string('createnewissuer', 'tool_oauth2'));
     echo $OUTPUT->footer();
 
index 395587a..6e5545f 100644 (file)
@@ -35,6 +35,7 @@ $string['createnewfacebookissuer'] = 'Create new Facebook service';
 $string['createnewgoogleissuer'] = 'Create new Google service';
 $string['createnewissuer'] = 'Create new custom service';
 $string['createnewmicrosoftissuer'] = 'Create new Microsoft service';
+$string['createnewnextcloudissuer'] = 'Create new Nextcloud service';
 $string['createnewuserfieldmapping'] = 'Create new user field mapping for issuer "{$a}"';
 $string['deleteconfirm'] = 'Are you sure you want to delete the identity issuer "{$a}"? Any plugins relying on this issuer will stop working.';
 $string['deleteendpointconfirm'] = 'Are you sure you want to delete the endpoint "{$a->endpoint}" for issuer "{$a->issuer}"? Any plugins relying on this endpoint will stop working.';
index 1b198cb..ab3c83d 100644 (file)
@@ -24,6 +24,7 @@
  */
 
 namespace core_badges\privacy;
+
 defined('MOODLE_INTERNAL') || die();
 
 use badge;
@@ -37,6 +38,8 @@ use core_privacy\local\metadata\collection;
 use core_privacy\local\request\approved_contextlist;
 use core_privacy\local\request\transform;
 use core_privacy\local\request\writer;
+use core_privacy\local\request\userlist;
+use core_privacy\local\request\approved_userlist;
 
 require_once($CFG->libdir . '/badgeslib.php');
 
@@ -50,6 +53,7 @@ require_once($CFG->libdir . '/badgeslib.php');
  */
 class provider implements
     \core_privacy\local\metadata\provider,
+    \core_privacy\local\request\core_userlist_provider,
     \core_privacy\local\request\subsystem\provider {
 
     /**
@@ -173,6 +177,78 @@ class provider implements
         return $contextlist;
     }
 
+    /**
+     * Get the list of users within a specific context.
+     *
+     * @param userlist $userlist The userlist containing the list of users who have data in this context/plugin combination.
+     */
+    public static function get_users_in_context(userlist $userlist) {
+        $context = $userlist->get_context();
+
+        $allowedcontexts = [
+            CONTEXT_COURSE,
+            CONTEXT_SYSTEM,
+            CONTEXT_USER
+        ];
+
+        if (!in_array($context->contextlevel, $allowedcontexts)) {
+            return;
+        }
+
+        if ($context->contextlevel == CONTEXT_COURSE || $context->contextlevel == CONTEXT_SYSTEM) {
+            // Find the modifications we made on badges (course & system).
+            $params = [
+                'courselevel' => CONTEXT_COURSE,
+                'syscontextid' => SYSCONTEXTID,
+                'typecourse' => BADGE_TYPE_COURSE,
+                'typesite' => BADGE_TYPE_SITE,
+                'contextid' => $context->id,
+            ];
+
+            $sql = "SELECT b.usermodified, b.usercreated
+                      FROM {badge} b
+                      JOIN {context} ctx
+                           ON (b.type = :typecourse AND b.courseid = ctx.instanceid AND ctx.contextlevel = :courselevel)
+                           OR (b.type = :typesite AND ctx.id = :syscontextid)
+                     WHERE ctx.id = :contextid";
+
+            $userlist->add_from_sql('usermodified', $sql, $params);
+            $userlist->add_from_sql('usercreated', $sql, $params);
+        }
+
+        if ($context->contextlevel == CONTEXT_USER) {
+            // Find where we've manually awarded a badge (recipient user context).
+            $params = [
+                'instanceid' => $context->instanceid
+            ];
+
+            $sql = "SELECT issuerid, recipientid
+                      FROM {badge_manual_award}
+                     WHERE recipientid = :instanceid";
+
+            $userlist->add_from_sql('issuerid', $sql, $params);
+            $userlist->add_from_sql('recipientid', $sql, $params);
+
+            $sql = "SELECT userid
+                      FROM {badge_issued}
+                     WHERE userid = :instanceid";
+
+            $userlist->add_from_sql('userid', $sql, $params);
+
+            $sql = "SELECT userid
+                      FROM {badge_criteria_met}
+                     WHERE userid = :instanceid";
+
+            $userlist->add_from_sql('userid', $sql, $params);
+
+            $sql = "SELECT userid
+                      FROM {badge_backpack}
+                     WHERE userid = :instanceid";
+
+            $userlist->add_from_sql('userid', $sql, $params);
+        }
+    }
+
     /**
      * Export all user data for the specified user, in the specified contexts.
      *
@@ -439,6 +515,24 @@ class provider implements
         static::delete_user_data($context->instanceid);
     }
 
+    /**
+     * Delete multiple users within a single context.
+     *
+     * @param approved_userlist $userlist The approved context and user information to delete information for.
+     */
+    public static function delete_data_for_users(approved_userlist $userlist) {
+        $context = $userlist->get_context();
+
+        if (!in_array($context->instanceid, $userlist->get_userids())) {
+            return;
+        }
+
+        if ($context->contextlevel == CONTEXT_USER) {
+            // We can only delete our own data in the user context, nothing in course or system.
+            static::delete_user_data($context->instanceid);
+        }
+    }
+
     /**
      * Delete all user data for the specified user, in the specified contexts.
      *
index bf3b084..1bfb93a 100644 (file)
@@ -32,6 +32,7 @@ use core_privacy\local\request\approved_contextlist;
 use core_privacy\local\request\transform;
 use core_privacy\local\request\writer;
 use core_badges\privacy\provider;
+use core_privacy\local\request\approved_userlist;
 
 require_once($CFG->libdir . '/badgeslib.php');
 
@@ -399,6 +400,177 @@ class core_badges_privacy_testcase extends provider_testcase {
         $this->assertEquals('Manager', $data->badges[0]['issuer_role']);
     }
 
+    /**
+     * Test that only users within a user, system and course context are fetched.
+     */
+    public function test_get_users_in_context() {
+        $component = 'core_badges';
+
+        // Create course1.
+        $course1 = $this->getDataGenerator()->create_course();
+        $coursecontext1 = context_course::instance($course1->id);
+        // Create course2.
+        $course2 = $this->getDataGenerator()->create_course();
+        $coursecontext2 = context_course::instance($course2->id);
+        // Create user1.
+        $user1 = $this->getDataGenerator()->create_user();
+        $usercontext1 = context_user::instance($user1->id);
+        // Create user2.
+        $user2 = $this->getDataGenerator()->create_user();
+        $usercontext2 = context_user::instance($user2->id);
+        // Create user3.
+        $user3 = $this->getDataGenerator()->create_user();
+        $usercontext3 = context_user::instance($user3->id);
+
+        // The list of users in usercontext1 should not return anything yet (related data still haven't been created).
+        $userlist1 = new \core_privacy\local\request\userlist($usercontext1, $component);
+        provider::get_users_in_context($userlist1);
+        $this->assertCount(0, $userlist1);
+        // The list of users in coursecontext1 should not return anything yet (related data still haven't been created).
+        $userlist2 = new \core_privacy\local\request\userlist($coursecontext1, $component);
+        provider::get_users_in_context($userlist2);
+        $this->assertCount(0, $userlist2);
+        // The list of users in systemcontext should not return anything yet (related data still haven't been created).
+        $systemcontext = context_system::instance();
+        $userlist3 = new \core_privacy\local\request\userlist($systemcontext, $component);
+        provider::get_users_in_context($userlist3);
+        $this->assertCount(0, $userlist3);
+
+        // Assert that we find contexts where we created/modified a badge.
+        $this->create_badge(['usercreated' => $user1->id, 'usermodified' => $user2->id]);
+        $badge1 = $this->create_badge(['usercreated' => $user2->id, 'type' => BADGE_TYPE_COURSE, 'courseid' => $course1->id]);
+        $badge2 = $this->create_badge(['usercreated' => $user3->id, 'usermodified' => $user1->id]);
+
+        $this->create_manual_award(['recipientid' => $user2->id, 'issuerid' => $user1->id, 'badgeid' => $badge1->id]);
+        $this->create_manual_award(['recipientid' => $user3->id, 'issuerid' => $user2->id, 'badgeid' => $badge1->id]);
+        $this->create_manual_award(['recipientid' => $user1->id, 'issuerid' => $user2->id, 'badgeid' => $badge2->id]);
+
+        $this->create_backpack(['userid' => $user2->id]);
+        $this->create_issued(['badgeid' => $badge2->id, 'userid' => $user3->id]);
+
+        $crit = $this->create_criteria_manual($badge1->id);
+        $crit->mark_complete($user3->id);
+
+        // The list of users for user context should return user1 and user2.
+        provider::get_users_in_context($userlist1);
+        $this->assertCount(2, $userlist1);
+        $this->assertTrue(in_array($user1->id, $userlist1->get_userids()));
+        $this->assertTrue(in_array($user2->id, $userlist1->get_userids()));
+
+        // The list of users for course context should return user2.
+        provider::get_users_in_context($userlist2);
+        $this->assertCount(1, $userlist2);
+        $this->assertTrue(in_array($user2->id, $userlist2->get_userids()));
+
+        // The list of users for system context should return user1, user2 and user3.
+        provider::get_users_in_context($userlist3);
+        $this->assertCount(3, $userlist3);
+        $this->assertTrue(in_array($user1->id, $userlist3->get_userids()));
+        $this->assertTrue(in_array($user2->id, $userlist3->get_userids()));
+        $this->assertTrue(in_array($user3->id, $userlist3->get_userids()));
+    }
+
+    /**
+     * Test that data for users in approved userlist is deleted.
+     */
+    public function test_delete_data_for_users() {
+        $component = 'core_badges';
+
+        // Create course1.
+        $course1 = $this->getDataGenerator()->create_course();
+        $coursecontext1 = context_course::instance($course1->id);
+        // Create course2.
+        $course2 = $this->getDataGenerator()->create_course();
+        $coursecontext2 = context_course::instance($course2->id);
+        // Create user1.
+        $user1 = $this->getDataGenerator()->create_user();
+        $usercontext1 = context_user::instance($user1->id);
+        // Create user2.
+        $user2 = $this->getDataGenerator()->create_user();
+        $usercontext2 = context_user::instance($user2->id);
+        // Create user3.
+        $user3 = $this->getDataGenerator()->create_user();
+        $usercontext3 = context_user::instance($user3->id);
+
+        $this->create_badge(['usercreated' => $user1->id, 'usermodified' => $user2->id]);
+        $badge1 = $this->create_badge(['usercreated' => $user2->id, 'type' => BADGE_TYPE_COURSE, 'courseid' => $course1->id]);
+        $badge2 = $this->create_badge(['usercreated' => $user3->id, 'type' => BADGE_TYPE_COURSE, 'courseid' => $course2->id,
+            'usermodified' => $user1->id]);
+
+        $this->create_manual_award(['recipientid' => $user2->id, 'issuerid' => $user1->id, 'badgeid' => $badge1->id]);
+        $this->create_manual_award(['recipientid' => $user3->id, 'issuerid' => $user2->id, 'badgeid' => $badge1->id]);
+        $this->create_manual_award(['recipientid' => $user1->id, 'issuerid' => $user2->id, 'badgeid' => $badge2->id]);
+
+        $this->create_backpack(['userid' => $user2->id]);
+        $this->create_issued(['badgeid' => $badge2->id, 'userid' => $user3->id]);
+
+        $crit = $this->create_criteria_manual($badge1->id);
+        $crit->mark_complete($user3->id);
+
+        // The list of users for usercontext2 context should return users.
+        $userlist1 = new \core_privacy\local\request\userlist($usercontext2, $component);
+        provider::get_users_in_context($userlist1);
+        $this->assertCount(2, $userlist1);
+        $this->assertTrue(in_array($user1->id, $userlist1->get_userids()));
+        $this->assertTrue(in_array($user2->id, $userlist1->get_userids()));
+
+        // The list of users for coursecontext2 context should return users.
+        $userlist2 = new \core_privacy\local\request\userlist($coursecontext2, $component);
+        provider::get_users_in_context($userlist2);
+        $this->assertCount(2, $userlist2);
+        $this->assertTrue(in_array($user1->id, $userlist2->get_userids()));
+        $this->assertTrue(in_array($user3->id, $userlist2->get_userids()));
+
+        // The list of users for system context should return users.
+        $systemcontext = context_system::instance();
+        $userlist3 = new \core_privacy\local\request\userlist($systemcontext, $component);
+        provider::get_users_in_context($userlist3);
+        $this->assertCount(2, $userlist3);
+        $this->assertTrue(in_array($user1->id, $userlist3->get_userids()));
+        $this->assertTrue(in_array($user2->id, $userlist3->get_userids()));
+
+        // Delete the data for user1 in usercontext2.
+        $approvedlist = new approved_userlist($usercontext2, $component, [$user1->id]);
+        // Delete using delete_data_for_user. No data for users in usercontext2 should be removed.
+        provider::delete_data_for_users($approvedlist);
+        // The list of users for usercontext2 context should still return user1, user2.
+        $userlist1 = new \core_privacy\local\request\userlist($usercontext2, $component);
+        provider::get_users_in_context($userlist1);
+        $this->assertCount(2, $userlist1);
+        $this->assertTrue(in_array($user1->id, $userlist1->get_userids()));
+        $this->assertTrue(in_array($user2->id, $userlist1->get_userids()));
+
+        // Delete the data for user2 in usercontext2.
+        $approvedlist = new approved_userlist($usercontext2, $component, [$user2->id]);
+        // Delete using delete_data_for_user. The user data in usercontext2 should be removed.
+        provider::delete_data_for_users($approvedlist);
+        // The list of users for usercontext2 context should not return any users.
+        $userlist1 = new \core_privacy\local\request\userlist($usercontext2, $component);
+        provider::get_users_in_context($userlist1);
+        $this->assertCount(0, $userlist1);
+
+        // The list of users for coursecontext2 context should return the previous users.
+        $userlist2 = new \core_privacy\local\request\userlist($coursecontext2, $component);
+        provider::get_users_in_context($userlist2);
+        $this->assertCount(2, $userlist2);
+
+        // The list of users for system context should return the previous users.
+        $systemcontext = context_system::instance();
+        $userlist3 = new \core_privacy\local\request\userlist($systemcontext, $component);
+        provider::get_users_in_context($userlist3);
+        $this->assertCount(2, $userlist3);
+
+        // Make sure data is only deleted in the user context, nothing in course or system.
+        // Convert $userlist2 into an approved_contextlist.
+        $approvedlist = new approved_userlist($coursecontext2, $component, $userlist2->get_userids());
+        provider::delete_data_for_users($approvedlist);
+
+        // The list of users for coursecontext2 context should still return the user data.
+        $userlist2 = new \core_privacy\local\request\userlist($coursecontext2, $component);
+        provider::get_users_in_context($userlist2);
+        $this->assertCount(2, $userlist2);
+    }
+
     /**
      * Create a badge.
      *
index cbee644..954f72b 100644 (file)
@@ -44,7 +44,8 @@ use core_privacy\local\request\writer;
 class provider implements
     \core_privacy\local\metadata\provider,
     \core_privacy\local\request\subsystem\provider,
-    \core_privacy\local\request\user_preference_provider {
+    \core_privacy\local\request\user_preference_provider,
+    \core_privacy\local\request\core_userlist_provider {
 
     /**
      * Returns metadata.
@@ -103,6 +104,28 @@ class provider implements
         return $contextlist;
     }
 
+    /**
+     * Get the list of contexts that contain user information for the specified user.
+     *
+     * @param   \core_privacy\local\request\userlist    $userlist   The userlist containing the list of users who have data in this context/plugin combination.
+     */
+    public static function get_users_in_context(\core_privacy\local\request\userlist $userlist) {
+        global $DB;
+
+        $context = $userlist->get_context();
+        if ($context->contextlevel != CONTEXT_BLOCK) {
+            return;
+        }
+
+        $params = ['docked' => 'docked_block_instance_' . $context->instanceid,
+                   'hidden' => 'block' . $context->instanceid . 'hidden'];
+
+        $sql = "SELECT userid
+                  FROM {user_preferences}
+                 WHERE name = :hidden OR name = :docked";
+        $userlist->add_from_sql('userid', $sql, $params);
+    }
+
     /**
      * Export all user data for the specified user, in the specified contexts.
      *
@@ -224,4 +247,24 @@ class provider implements
         $DB->delete_records_select('user_preferences', $sql, $params);
     }
 
+
+    /**
+     * Delete multiple users within a single context.
+     *
+     * @param \core_privacy\local\request\approved_userlist $userlist The approved context and user information to delete
+     * information for.
+     */
+    public static function delete_data_for_users(\core_privacy\local\request\approved_userlist $userlist) {
+        global $DB;
+        $context = $userlist->get_context();
+        if ($context->contextlevel != CONTEXT_BLOCK) {
+            return;
+        }
+
+        list($insql, $params) = $DB->get_in_or_equal($userlist->get_userids(), SQL_PARAMS_NAMED);
+        $params['hidden'] = 'block' . $context->instanceid . 'hidden';
+        $params['docked'] = 'docked_block_instance_' . $context->instanceid;
+
+        $DB->delete_records_select('user_preferences', "(name = :hidden OR name = :docked) AND userid $insql", $params);
+    }
 }
index ce2592d..5ca1a22 100644 (file)
Binary files a/blocks/myoverview/amd/build/main.min.js and b/blocks/myoverview/amd/build/main.min.js differ
diff --git a/blocks/myoverview/amd/build/selectors.min.js b/blocks/myoverview/amd/build/selectors.min.js
new file mode 100644 (file)
index 0000000..a07864c
Binary files /dev/null and b/blocks/myoverview/amd/build/selectors.min.js differ
index 5f3fb07..f674567 100644 (file)
Binary files a/blocks/myoverview/amd/build/view.min.js and b/blocks/myoverview/amd/build/view.min.js differ
index 7af70de..66a3cba 100644 (file)
Binary files a/blocks/myoverview/amd/build/view_nav.min.js and b/blocks/myoverview/amd/build/view_nav.min.js differ
index e59e62e..2582cbd 100644 (file)
@@ -32,12 +32,6 @@ function(
     View,
     ViewNav
 ) {
-
-    var SELECTORS = {
-        COURSES_VIEW: '[data-region="courses-view"]',
-        COURSES_VIEW_CONTENT: '[data-region="course-view-content"]'
-    };
-
     /**
      * Initialise all of the modules for the overview block.
      *
@@ -45,12 +39,10 @@ function(
      */
     var init = function(root) {
         root = $(root);
-        var coursesViewRoot = root.find(SELECTORS.COURSES_VIEW);
-        var coursesViewContent = root.find(SELECTORS.COURSES_VIEW_CONTENT);
         // Initialise the course navigation elements.
-        ViewNav.init(root, coursesViewRoot, coursesViewContent);
+        ViewNav.init(root);
         // Initialise the courses view modules.
-        View.init(coursesViewRoot, coursesViewContent);
+        View.init(root);
     };
 
     return {
diff --git a/blocks/myoverview/amd/src/selectors.js b/blocks/myoverview/amd/src/selectors.js
new file mode 100644 (file)
index 0000000..21e6a92
--- /dev/null
@@ -0,0 +1,31 @@
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Javascript to initialise the selectors for the myoverview block.
+ *
+ * @package    block_myoverview
+ * @copyright  2018 Peter Dias <peter@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+define([], function() {
+    return {
+        courseView: {
+            region: '[data-region="courses-view"]',
+            regionContent: '[data-region="course-view-content"]'
+        }
+    };
+});
index f4304fa..e5dc3c3 100644 (file)
@@ -26,20 +26,29 @@ define(
     'jquery',
     'block_myoverview/repository',
     'core/paged_content_factory',
+    'core/pubsub',
     'core/custom_interaction_events',
     'core/notification',
     'core/templates',
+    'core_course/events',
+    'block_myoverview/selectors'
 ],
 function(
     $,
     Repository,
     PagedContentFactory,
+    PubSub,
     CustomEvents,
     Notification,
-    Templates
+    Templates,
+    CourseEvents,
+    Selectors
 ) {
 
     var SELECTORS = {
+        COURSE_REGION: '[data-region="course-view-content"]',
+        ACTION_HIDE_COURSE: '[data-action="hide-course"]',
+        ACTION_SHOW_COURSE: '[data-action="show-course"]',
         ACTION_ADD_FAVOURITE: '[data-action="add-favourite"]',
         ACTION_REMOVE_FAVOURITE: '[data-action="remove-favourite"]',
         FAVOURITE_ICON: '[data-region="favourite-icon"]',
@@ -60,6 +69,10 @@ function(
 
     var loadedPages = [];
 
+    var courseOffset = 0;
+
+    var lastPage = 0;
+
     /**
      * Get filter values from DOM.
      *
@@ -67,11 +80,12 @@ function(
      * @return {filters} Set filters.
      */
     var getFilterValues = function(root) {
-        var filters = {};
-        filters.display = root.attr('data-display');
-        filters.grouping = root.attr('data-grouping');
-        filters.sort = root.attr('data-sort');
-        return filters;
+        var courseRegion = root.find(Selectors.courseView.region);
+        return {
+            display: courseRegion.attr('data-display'),
+            grouping: courseRegion.attr('data-grouping'),
+            sort: courseRegion.attr('data-sort')
+        };
     };
 
     // We want the paged content controls below the paged content area.
@@ -86,13 +100,12 @@ function(
      *
      * @param {object} filters The filters for this view.
      * @param {int} limit The number of courses to show.
-     * @param {int} pageNumber The pagenumber to view.
      * @return {promise} Resolved with an array of courses.
      */
-    var getMyCourses = function(filters, limit, pageNumber) {
+    var getMyCourses = function(filters, limit) {
 
         return Repository.getEnrolledCoursesByTimeline({
-            offset:  pageNumber * limit,
+            offset: courseOffset,
             limit: limit,
             classification: filters.grouping,
             sort: filters.sort
@@ -127,7 +140,7 @@ function(
      * @param {Object} root The favourite icon container element.
      * @return {Number} Course id.
      */
-    var getFavouriteCourseId = function(root) {
+    var getCourseId = function(root) {
         return root.attr('data-course-id');
     };
 
@@ -197,6 +210,7 @@ function(
 
         setCourseFavouriteState(courseId, true).then(function(success) {
             if (success) {
+                PubSub.publish(CourseEvents.favourited);
                 removeAction.removeClass('hidden');
                 addAction.addClass('hidden');
                 showFavouriteIcon(root, courseId);
@@ -219,6 +233,7 @@ function(
 
         setCourseFavouriteState(courseId, false).then(function(success) {
             if (success) {
+                PubSub.publish(CourseEvents.unfavorited);
                 removeAction.addClass('hidden');
                 addAction.removeClass('hidden');
                 hideFavouriteIcon(root, courseId);
@@ -229,6 +244,61 @@ function(
         }).catch(Notification.exception);
     };
 
+    /**
+     * Reset the loadedPages dataset to take into account the hidden element
+     *
+     * @param {Object} root The course overview container
+     * @param {Object} target The course that you want to hide
+     */
+    var hideElement = function(root, target) {
+        var id = getCourseId(target);
+
+        var pagingBar = root.find('[data-region="paging-bar"]');
+        var jumpto = parseInt(pagingBar.attr('data-active-page-number'));
+
+        // Get a reduced dataset for the current page.
+        var courseList = loadedPages[jumpto];
+        var reducedCourse = courseList.courses.reduce(function(accumulator, current) {
+            if (id != current.id) {
+                accumulator.push(current);
+            }
+            return accumulator;
+        }, []);
+
+        // Get the next page's data if loaded and pop the first element from it
+        if (loadedPages[jumpto + 1] != undefined) {
+            var newElement = loadedPages[jumpto + 1].courses.slice(0, 1);
+            loadedPages[jumpto + 1].courses = loadedPages[jumpto + 1].courses.slice(1);
+
+            reducedCourse = $.merge(reducedCourse, newElement);
+        }
+
+        // Check if the next page is the last page and if it still has data associated to it
+        if (lastPage == jumpto + 1 && loadedPages[jumpto + 1].courses.length == 0) {
+            var pagedContentContainer = root.find('[data-region="paged-content-container"]');
+            PagedContentFactory.resetLastPageNumber($(pagedContentContainer).attr('id'), jumpto);
+        }
+
+        loadedPages[jumpto].courses = reducedCourse;
+
+        // Reduce the course offset
+        courseOffset--;
+
+        // Render the paged content for the current
+        var pagedContentPage = getPagedContentContainer(root, jumpto);
+        renderCourses(root, loadedPages[jumpto]).then(function(html, js) {
+            return Templates.replaceNodeContents(pagedContentPage, html, js);
+        }).catch(Notification.exception);
+
+        // Delete subsequent pages in order to trigger the callback
+        loadedPages.forEach(function(courseList, index) {
+            if (index > jumpto) {
+                var page = getPagedContentContainer(root, index);
+                page.remove();
+            }
+        });
+    };
+
     /**
      * Set the courses favourite status and push to repository
      *
@@ -286,7 +356,7 @@ function(
                 courses: coursesData.courses
             });
         } else {
-            var nocoursesimg = root.attr('data-nocoursesimg');
+            var nocoursesimg = root.find(Selectors.courseView.region).attr('data-nocoursesimg');
             return Templates.render(TEMPLATES.NOCOURSES, {
                 nocoursesimg: nocoursesimg
             });
@@ -294,20 +364,12 @@ function(
     };
 
     /**
-     * Intialise the courses list and cards views on page load.
+     * Intialise the paged list and cards views on page load.
      *
      * @param {object} root The root element for the courses view.
      * @param {object} content The content element for the courses view.
      */
-    var init = function(root, content) {
-
-        root = $(root);
-
-        if (!root.attr('data-init')) {
-            registerEventListeners(root);
-            root.attr('data-init', true);
-        }
-
+    var initializePagedContent = function(root) {
         var filters = getFilterValues(root);
 
         var pagedContentPromise = PagedContentFactory.createWithLimit(
@@ -317,18 +379,65 @@ function(
 
                 pagesData.forEach(function(pageData) {
                     var currentPage = pageData.pageNumber;
-                    var pageNumber = pageData.pageNumber - 1;
+                    var limit = pageData.limit;
+
+                    if (lastPage == currentPage) {
+                        // If we are on the last page and have it's data then load it from cache
+                        actions.allItemsLoaded(lastPage);
+                        promises.push(renderCourses(root, loadedPages[currentPage]));
+                        return;
+                    }
+
+                    // Get 2 pages worth of data as we will need it for the hidden functionality.
+                    if (loadedPages[currentPage + 1] == undefined) {
+                        if (loadedPages[currentPage] == undefined) {
+                            limit *= 2;
+                        }
+                    }
 
                     var pagePromise = getMyCourses(
                         filters,
-                        pageData.limit,
-                        pageNumber
+                        limit
                     ).then(function(coursesData) {
-                        if (coursesData.courses.length < pageData.limit) {
-                            actions.allItemsLoaded(pageData.pageNumber);
+                        var courses = coursesData.courses;
+                        var nextPageStart = 0;
+                        var pageCourses = [];
+
+                        // If current page's data is loaded make sure we max it to page limit
+                        if (loadedPages[currentPage] != undefined) {
+                            pageCourses = loadedPages[currentPage].courses;
+                            var currentPageLength = pageCourses.length;
+                            if (currentPageLength < pageData.limit) {
+                                nextPageStart = pageData.limit - currentPageLength;
+                                pageCourses = $.merge(loadedPages[currentPage].courses, courses.slice(0, nextPageStart));
+                            }
+                        } else {
+                            nextPageStart = pageData.limit;
+                            pageCourses = courses.slice(0, pageData.limit);
                         }
-                        loadedPages[currentPage] = coursesData;
-                        return renderCourses(root, coursesData);
+
+                        // Finished setting up the current page
+                        loadedPages[currentPage] = {
+                            courses: pageCourses
+                        };
+
+                        // Set up the next page
+                        var remainingCourses = courses.slice(nextPageStart, courses.length);
+                        loadedPages[currentPage + 1] = {
+                            courses: remainingCourses
+                        };
+
+                        // Set the last page to either the current or next page
+                        if (loadedPages[currentPage].courses.length < pageData.limit) {
+                            lastPage = currentPage;
+                            actions.allItemsLoaded(currentPage);
+                        } else if (loadedPages[currentPage + 1] != undefined
+                            && loadedPages[currentPage + 1].courses.length < pageData.limit) {
+                            lastPage = currentPage + 1;
+                        }
+
+                        courseOffset = coursesData.nextoffset;
+                        return renderCourses(root, loadedPages[currentPage]);
                     })
                     .catch(Notification.exception);
 
@@ -341,7 +450,7 @@ function(
         );
 
         pagedContentPromise.then(function(html, js) {
-            return Templates.replaceNodeContents(content, html, js);
+            return Templates.replaceNodeContents(root.find(Selectors.courseView.region), html, js);
         }).catch(Notification.exception);
     };
 
@@ -357,14 +466,14 @@ function(
 
         root.on(CustomEvents.events.activate, SELECTORS.ACTION_ADD_FAVOURITE, function(e, data) {
             var favourite = $(e.target).closest(SELECTORS.ACTION_ADD_FAVOURITE);
-            var courseId = getFavouriteCourseId(favourite);
+            var courseId = getCourseId(favourite);
             addToFavourites(root, courseId);
             data.originalEvent.preventDefault();
         });
 
         root.on(CustomEvents.events.activate, SELECTORS.ACTION_REMOVE_FAVOURITE, function(e, data) {
             var favourite = $(e.target).closest(SELECTORS.ACTION_REMOVE_FAVOURITE);
-            var courseId = getFavouriteCourseId(favourite);
+            var courseId = getCourseId(favourite);
             removeFromFavourites(root, courseId);
             data.originalEvent.preventDefault();
         });
@@ -372,20 +481,75 @@ function(
         root.on(CustomEvents.events.activate, SELECTORS.FAVOURITE_ICON, function(e, data) {
             data.originalEvent.preventDefault();
         });
+
+        root.on(CustomEvents.events.activate, SELECTORS.ACTION_HIDE_COURSE, function(e, data) {
+            var target = $(e.target).closest(SELECTORS.ACTION_HIDE_COURSE);
+            var id = getCourseId(target);
+
+            var request = {
+                preferences: [
+                    {
+                        type: 'block_myoverview_hidden_course_' + id,
+                        value: true
+                    }
+                ]
+            };
+            Repository.updateUserPreferences(request);
+
+            hideElement(root, target);
+            data.originalEvent.preventDefault();
+        });
+
+        root.on(CustomEvents.events.activate, SELECTORS.ACTION_SHOW_COURSE, function(e, data) {
+            var target = $(e.target).closest(SELECTORS.ACTION_SHOW_COURSE);
+            var id = getCourseId(target);
+
+            var request = {
+                preferences: [
+                    {
+                        type: 'block_myoverview_hidden_course_' + id,
+                        value: null
+                    }
+                ]
+            };
+
+            Repository.updateUserPreferences(request);
+
+            hideElement(root, target);
+            data.originalEvent.preventDefault();
+        });
+    };
+
+    /**
+     * Intialise the courses list and cards views on page load.
+     *
+     * @param {object} root The root element for the courses view.
+     */
+    var init = function(root) {
+        root = $(root);
+        loadedPages = [];
+        lastPage = 0;
+        courseOffset = 0;
+
+        if (!root.attr('data-init')) {
+            registerEventListeners(root);
+            root.attr('data-init', true);
+        }
+
+        initializePagedContent(root);
     };
 
     /**
+
      * Reset the courses views to their original
-     * state on first page load.
+     * state on first page load.courseOffset
      *
      * This is called when configuration has changed for the event lists
      * to cause them to reload their data.
      *
      * @param {Object} root The root element for the timeline view.
-     * @param {Object} content The content element for the timeline view.
      */
-    var reset = function(root, content) {
-
+    var reset = function(root) {
         if (loadedPages.length > 0) {
             loadedPages.forEach(function(courseList, index) {
                 var pagedContentPage = getPagedContentContainer(root, index);
@@ -394,7 +558,7 @@ function(
                 }).catch(Notification.exception);
             });
         } else {
-            init(root, content);
+            init(root);
         }
     };
 
index 49f211d..29d114e 100644 (file)
@@ -26,13 +26,15 @@ define(
     'jquery',
     'core/custom_interaction_events',
     'block_myoverview/repository',
-    'block_myoverview/view'
+    'block_myoverview/view',
+    'block_myoverview/selectors'
 ],
 function(
     $,
     CustomEvents,
     Repository,
-    View
+    View,
+    Selectors
 ) {
 
     var SELECTORS = {
@@ -71,10 +73,8 @@ function(
      * Event listener for the Display filter (cards, list).
      *
      * @param {object} root The root element for the overview block
-     * @param {object} viewRoot The root element for displaying courses.
-     * @param {object} viewContent content The content element for the courses view.
      */
-    var registerSelector = function(root, viewRoot, viewContent) {
+    var registerSelector = function(root) {
 
         var Selector = root.find(SELECTORS.FILTERS);
 
@@ -91,16 +91,13 @@ function(
                 }
 
                 var filter = option.attr('data-filter');
-                var attributename = 'data-' + filter;
-                var value = option.attr('data-value');
                 var pref = option.attr('data-pref');
 
-                viewRoot.attr(attributename, value);
-
+                root.find(Selectors.courseView.region).attr('data-' + filter, option.attr('data-value'));
                 updatePreferences(filter, pref);
 
                 // Reset the views.
-                View.init(viewRoot, viewContent);
+                View.init(root);
 
                 data.originalEvent.preventDefault();
             }
@@ -118,12 +115,11 @@ function(
                 }
 
                 var filter = option.attr('data-display-option');
-                var value = option.attr('data-value');
                 var pref = option.attr('data-pref');
 
+                root.find(Selectors.courseView.region).attr('data-display', option.attr('data-value'));
                 updatePreferences(filter, pref);
-                viewRoot.attr('data-display', value);
-                View.reset(viewRoot, viewContent);
+                View.reset(root);
                 data.originalEvent.preventDefault();
             }
         );
@@ -134,12 +130,10 @@ function(
      * the navigation elements.
      *
      * @param {object} root The root element for the myoverview block
-     * @param {object} viewRoot The root element for the myoverview block
-     * @param {object} viewContent The content element for the myoverview block
      */
-    var init = function(root, viewRoot, viewContent) {
+    var init = function(root) {
         root = $(root);
-        registerSelector(root, viewRoot, viewContent);
+        registerSelector(root);
     };
 
     return {
index 34463a1..be95345 100644 (file)
@@ -26,6 +26,7 @@ namespace block_myoverview\privacy;
 
 use core_privacy\local\request\user_preference_provider;
 use core_privacy\local\metadata\collection;
+use \core_privacy\local\request\writer;
 
 defined('MOODLE_INTERNAL') || die();
 
@@ -58,14 +59,14 @@ class provider implements \core_privacy\local\metadata\provider, user_preference
     public static function export_user_preferences(int $userid) {
         $preference = get_user_preferences('block_myoverview_user_sort_preference', null, $userid);
         if (isset($preference)) {
-            \core_privacy\local\request\writer::export_user_preference('block_myoverview',
+            writer::export_user_preference('block_myoverview',
                 'block_myoverview_user_sort_preference', get_string($preference, 'block_myoverview'),
                 get_string('privacy:metadata:overviewsortpreference', 'block_myoverview'));
         }
 
         $preference = get_user_preferences('block_myoverview_user_view_preference', null, $userid);
         if (isset($preference)) {
-            \core_privacy\local\request\writer::export_user_preference('block_myoverview',
+            writer::export_user_preference('block_myoverview',
                 'block_myoverview_user_view_preference',
                 get_string($preference, 'block_myoverview'),
                 get_string('privacy:metadata:overviewviewpreference', 'block_myoverview'));
@@ -73,10 +74,25 @@ class provider implements \core_privacy\local\metadata\provider, user_preference
 
         $preference = get_user_preferences('block_myoverview_user_grouping_preference', null, $userid);
         if (isset($preference)) {
-            \core_privacy\local\request\writer::export_user_preference('block_myoverview',
+            writer::export_user_preference('block_myoverview',
                 'block_myoverview_user_grouping_preference',
                 get_string($preference, 'block_myoverview'),
                 get_string('privacy:metadata:overviewgroupingpreference', 'block_myoverview'));
         }
+
+        $preferences = get_user_preferences(null, null, $userid);
+        foreach ($preferences as $name => $value) {
+            if ((substr($name, 0, 30) == 'block_myoverview_hidden_course')) {
+                writer::export_user_preference(
+                    'block_myoverview',
+                    $name,
+                    $value,
+                    get_string('privacy:request:preference:set', 'block_myoverview', (object) [
+                        'name' => $name,
+                        'value' => $value,
+                    ])
+                );
+            }
+        }
     }
 }
\ No newline at end of file
index 9500656..7a7e2e2 100644 (file)
@@ -29,12 +29,9 @@ $string['aria:allcourses'] = 'All courses';
 $string['aria:card'] = 'Switch to card view';
 $string['aria:controls'] = 'Course overview controls';
 $string['aria:courseactions'] = 'Actions for current course';
-$string['aria:courseimage'] = 'Course image:';
-$string['aria:coursename'] = 'Course name:';
 $string['aria:coursesummary'] = 'Course summary text:';
 $string['aria:courseprogress'] = 'Course progress:';
 $string['aria:displaydropdown'] = 'Display dropdown';
-$string['aria:favourite'] = 'Course is starred';
 $string['aria:favourites'] = 'Show starred courses';
 $string['aria:future'] = 'Show future courses';
 $string['aria:groupingdropdown'] = 'Grouping dropdown';
@@ -48,12 +45,11 @@ $string['aria:summary'] = 'Switch to summary view';
 $string['aria:sortingdropdown'] = 'Sorting dropdown';
 $string['card'] = 'Card';
 $string['cards'] = 'Cards';
+$string['courseprogress'] = 'Course progress:';
 $string['complete'] = 'Complete';
-$string['favourite'] = 'Starred course';
 $string['favourites'] = 'Starred';
 $string['future'] = 'Future';
 $string['inprogress'] = 'In progress';
-$string['notfavourite'] = 'Not starred';
 $string['lastaccessed'] = 'Last accessed';
 $string['list'] = 'List';
 $string['myoverview:addinstance'] = 'Add a new course overview block';
@@ -67,6 +63,14 @@ $string['privacy:metadata:overviewgroupingpreference'] = 'The Course overview bl
 $string['removefromfavourites'] = 'Unstar this course';
 $string['summary'] = 'Summary';
 $string['title'] = 'Title';
+$string['aria:hidecourse'] = 'Hide {$a} from view';
+$string['aria:showcourse'] = 'Show {$a} in view';
+$string['aria:hiddencourses'] = 'Show hidden courses';
+$string['hidden'] = 'Hidden courses';
+$string['hidecourse'] = 'Hide from view';
+$string['hiddencourses'] = 'Hidden';
+$string['show'] = 'Show this course';
+$string['privacy:request:preference:set'] = 'The value of the setting \'{$a->name}\' was \'{$a->value}\'';
 
 // Deprecated since Moodle 3.6.
 $string['defaulttab'] = 'Default tab';
@@ -84,4 +88,4 @@ $string['sortbydates'] = 'Sort by dates';
 $string['timeline'] = 'Timeline';
 $string['viewcoursename'] = 'View course {$a}';
 $string['privacy:metadata:overviewlasttab'] = 'This stores the last tab selected by the user on the overview block.';
-
+$string['viewcourse'] = 'View course';
index bc0530e..3244e4c 100644 (file)
@@ -32,6 +32,7 @@ define('BLOCK_MYOVERVIEW_GROUPING_INPROGRESS', 'inprogress');
 define('BLOCK_MYOVERVIEW_GROUPING_FUTURE', 'future');
 define('BLOCK_MYOVERVIEW_GROUPING_PAST', 'past');
 define('BLOCK_MYOVERVIEW_GROUPING_FAVOURITES', 'favourites');
+define('BLOCK_MYOVERVIEW_GROUPING_HIDDEN', 'hidden');
 
 /**
  * Constants for the user preferences sorting options
@@ -62,7 +63,8 @@ function block_myoverview_user_preferences() {
             BLOCK_MYOVERVIEW_GROUPING_INPROGRESS,
             BLOCK_MYOVERVIEW_GROUPING_FUTURE,
             BLOCK_MYOVERVIEW_GROUPING_PAST,
-            BLOCK_MYOVERVIEW_GROUPING_FAVOURITES
+            BLOCK_MYOVERVIEW_GROUPING_FAVOURITES,
+            BLOCK_MYOVERVIEW_GROUPING_HIDDEN
         )
     );
     $preferences['block_myoverview_user_sort_preference'] = array(
@@ -84,5 +86,14 @@ function block_myoverview_user_preferences() {
             BLOCK_MYOVERVIEW_VIEW_SUMMARY
         )
     );
+
+    $preferences['/^block_myoverview_hidden_course_(\d)+$/'] = array(
+        'isregex' => true,
+        'choices' => array(0, 1),
+        'type' => PARAM_INT,
+        'null' => NULL_NOT_ALLOWED,
+        'default' => 'none'
+    );
+
     return $preferences;
-}
\ No newline at end of file
+}
index 027bdab..b1afa75 100644 (file)
@@ -41,7 +41,7 @@
             data-course-id="{{id}}"
             aria-controls="favorite-icon-{{ id }}"
             >
-            {{#pix}} i/star, core, {{#str}} favourite, block_myoverview {{/str}} {{/pix}}
+            {{#pix}} i/star, core, {{#str}} favourites, block_myoverview {{/str}} {{/pix}}
             {{#str}} addtofavourites, block_myoverview {{/str}}
             <div class="sr-only">
                 {{#str}} aria:addtofavourites, block_myoverview {{/str}} {{{fullname}}}
                 {{#str}} aria:removefromfavourites, block_myoverview {{/str}} {{{fullname}}}
             </div>
         </a>
+        <a class="dropdown-item {{^hidden}}hidden{{/hidden}}" href="#"
+            data-action="show-course"
+            data-course-id="{{id}}"
+            aria-controls="favorite-icon-{{ id }}"
+            >
+            {{#pix}} i/show, core, {{#str}} hidden, block_myoverview {{/str}} {{/pix}}
+            {{#str}} show, block_myoverview {{/str}}
+            <div class="sr-only">
+                {{#str}} aria:showcourse, block_myoverview, {{fullname}} {{/str}}
+            </div>
+        </a>
+        <a class="dropdown-item {{#hidden}}hidden{{/hidden}}" href="#"
+            data-action="hide-course"
+            data-course-id="{{id}}"
+            aria-controls="favorite-icon-{{ id }}"
+            >
+            {{#pix}} i/hide, core, {{#str}} hidden, block_myoverview {{/str}} {{/pix}}
+            {{#str}} hidecourse, block_myoverview {{/str}}
+            <div class="sr-only">
+                {{#str}} aria:hidecourse, block_myoverview, {{fullname}} {{/str}}
+            </div>
+        </a>
     </div>
 </div>
\ No newline at end of file
index d8f2aa4..c5be1e8 100644 (file)
@@ -32,6 +32,7 @@
     data-display="{{view}}"
     data-grouping="{{grouping}}"
     data-sort="{{sort}}"
+    data-prev-display="{{view}}"
     data-nocoursesimg="{{nocoursesimg}}">
     <div data-region="course-view-content">
         <div data-region="courses-loading-placeholder">
index 9e341a6..687e1a3 100644 (file)
@@ -36,6 +36,7 @@
             {{#future}}{{#str}} future, block_myoverview {{/str}}{{/future}}
             {{#past}}{{#str}} past, block_myoverview {{/str}}{{/past}}
             {{#favourites}}{{#str}} favourites, block_myoverview {{/str}}{{/favourites}}
+            {{#hidden}}{{#str}} hiddencourses, block_myoverview {{/str}}{{/hidden}}
         </span>
     </button>
     <ul class="dropdown-menu" data-show-active-item data-active-item-text aria-labelledby="groupingdropdown">
                 {{#str}} favourites, block_myoverview {{/str}}
             </a>
         </li>
+        <li>
+            <a class="dropdown-item {{#hidden}}active{{/hidden}}" href="#" data-filter="grouping" data-value="hidden"  data-pref="hidden" aria-label="{{#str}} aria:hiddencourses, block_myoverview {{/str}}" aria-controls="courses-view-{{uniqid}}">
+                {{#str}} hiddencourses, block_myoverview {{/str}}
+            </a>
+        </li>
     </ul>
 </div>
index ce97c1f..74408f9 100644 (file)
 
 <div class="card-deck dashboard-card-deck" role="list">
 {{#courses}}
-    <div class="card dashboard-card" role="listitem">
+    <div class="card dashboard-card" role="listitem"
+        data-region="course-content"
+        data-course-id="{{{id}}}">
         <a href="{{viewurl}}" tabindex="-1">
             <div class="card-img dashboard-card-img" style='background-image: url("{{{courseimage}}}");'>
-                <span class="sr-only">{{#str}}aria:courseimage, block_myoverview{{/str}}</span>
-                {{> block_myoverview/favourite-icon }}
+                <span class="sr-only">{{#str}}aria:courseimage, core_course{{/str}}</span>
+                {{> core_course/favouriteicon }}
             </div>
         </a>
         <div class="card-body pr-1 course-info-container" id="course-info-container-{{id}}">
             <div class="d-flex align-items-start">
                 <div class="card-title mr-2">
                     <a href="{{viewurl}}">
-                        <span class="sr-only">{{#str}}aria:coursename, block_myoverview{{/str}}</span>
+                        <span class="sr-only">{{#str}}aria:coursename, core_course{{/str}}</span>
                         {{#shortentext}}40, {{{fullname}}} {{/shortentext}}
                     </a>
                 </div>
index 389036b..617ba20 100644 (file)
 
 <ul class="list-group">
 {{#courses}}
-    <li class="list-group-item course-listitem">
+    <li class="list-group-item course-listitem"
+        data-region="course-content"
+        data-course-id="{{{id}}}">
         <div class="row-fluid">
             <div class="{{#hasprogress}}col-6 span6{{/hasprogress}}{{^hasprogress}}col-11 span11{{/hasprogress}} p-l-0">
                 <div class="d-flex align-items-center">
-                    {{> block_myoverview/favourite-icon }}
+                    {{> core_course/favouriteicon }}
                     <a href="{{viewurl}}">
-                        <span class="sr-only">{{#str}}aria:coursename, block_myoverview{{/str}}</span>
+                        <span class="sr-only">{{#str}}aria:coursename, core_course{{/str}}</span>
                         {{{fullname}}}
                     </a>
                 </div>
index 0470ca4..061aac4 100644 (file)
 }}
 <div role="list">
 {{#courses}}
-    <div class="course-summaryitem m-b-1 p-2" role="listitem">
+    <div class="course-summaryitem m-b-1 p-2" role="listitem"
+        data-region="course-content"
+        data-course-id="{{{id}}}">
         <div class="row-fluid d-flex">
             <a href="{{viewurl}}" class="col-sm-4 col-xl-3 span4 position-relative" tabindex="-1">
                 <div class="position-absolute">
-                    {{> block_myoverview/favourite-icon }}
+                    {{> core_course/favouriteicon }}
                 </div>
                 <div class="card-img-top summaryimage" style='background-image: url("{{{courseimage}}}");'>
-                    <span class="sr-only">{{#str}}aria:courseimage, block_myoverview{{/str}}</span>
+                    <span class="sr-only">{{#str}}aria:courseimage, core_course{{/str}}</span>
                 </div>
             </a>
             <div class="col-sm-8 col-xl-9 span8 align-self-stretch d-flex flex-column">
                 <div class="d-flex">
                     <a href="{{viewurl}}">
-                        <span class="sr-only">{{#str}}aria:coursename, block_myoverview{{/str}}</span>
+                        <span class="sr-only">{{#str}}aria:coursename, core_course{{/str}}</span>
                         <h4>{{{fullname}}}</h4>
                     </a>
                     {{> block_myoverview/course-action-menu }}
index f63c118..427be51 100644 (file)
@@ -157,4 +157,46 @@ Feature: The my overview block allows users to easily access their courses
     And I click on "Last accessed" "link" in the "Course overview" "block"
     And I reload the page
     Then I should see "Last accessed" in the "Course overview" "block"
-    And "[data-sort='ul.timeaccess desc']" "css_element" in the "Course overview" "block" should be visible
\ No newline at end of file
+    And "[data-sort='ul.timeaccess desc']" "css_element" in the "Course overview" "block" should be visible
+
+  Scenario: View inprogress courses with hide persistent functionality
+    Given I log in as "student1"
+    And I click on "All" "button" in the "Course overview" "block"
+    When I click on "In progress" "link" in the "Course overview" "block"
+    And I click on ".coursemenubtn" "css_element" in the "//div[@class='card dashboard-card' and contains(.,'Course 2')]" "xpath_element"
+    And I click on "Hide from view" "link" in the "//div[@class='card dashboard-card' and contains(.,'Course 2')]" "xpath_element"
+    And I reload the page
+    Then I should see "Course 3" in the "Course overview" "block"
+    Then I should see "Course 4" in the "Course overview" "block"
+    And I should not see "Course 2" in the "Course overview" "block"
+    And I should not see "Course 1" in the "Course overview" "block"
+    And I should not see "Course 5" in the "Course overview" "block"
+    And I log out
+
+  Scenario: View past courses with hide persistent functionality
+    Given I log in as "student1"
+    And I click on "All" "button" in the "Course overview" "block"
+    When I click on "Past" "link" in the "Course overview" "block"
+    And I click on ".coursemenubtn" "css_element" in the "//div[@class='card dashboard-card' and contains(.,'Course 1')]" "xpath_element"
+    And I click on "Hide from view" "link" in the "//div[@class='card dashboard-card' and contains(.,'Course 1')]" "xpath_element"
+    And I reload the page
+    Then I should not see "Course 1" in the "Course overview" "block"
+    And I should not see "Course 2" in the "Course overview" "block"
+    And I should not see "Course 3" in the "Course overview" "block"
+    And I should not see "Course 4" in the "Course overview" "block"
+    And I should not see "Course 5" in the "Course overview" "block"
+    And I log out
+
+  Scenario: View future courses with hide persistent functionality
+    Given I log in as "student1"
+    And I click on "All" "button" in the "Course overview" "block"
+    When I click on "Future" "link" in the "Course overview" "block"
+    And I click on ".coursemenubtn" "css_element" in the "//div[@class='card dashboard-card' and contains(.,'Course 5')]" "xpath_element"
+    And I click on "Hide from view" "link" in the "//div[@class='card dashboard-card' and contains(.,'Course 5')]" "xpath_element"
+    And I reload the page
+    Then I should not see "Course 5" in the "Course overview" "block"
+    And I should not see "Course 1" in the "Course overview" "block"
+    And I should not see "Course 2" in the "Course overview" "block"
+    And I should not see "Course 3" in the "Course overview" "block"
+    And I should not see "Course 4" in the "Course overview" "block"
+    And I log out
index 973b634..08527be 100644 (file)
@@ -28,11 +28,11 @@ Feature: The my overview block allows users to favourite their courses
     When I click on ".coursemenubtn" "css_element" in the "//div[@class='card dashboard-card' and contains(.,'Course 2')]" "xpath_element"
     And I click on "Star this course" "link" in the "//div[@class='card dashboard-card' and contains(.,'Course 2')]" "xpath_element"
     And I reload the page
-    Then "//div[@role='listitem' and contains(.,'Course 2')]//span[@data-region='is-favourite' and @aria-hidden='false']" "xpath_element" should exist
-    And "//div[@role='listitem' and contains(.,'Course 2')]//span[@data-region='is-favourite' and @aria-hidden='true']" "xpath_element" should not exist
-    And "//div[@role='listitem' and contains(.,'Course 2')]//span[@data-region='not-favourite' and @aria-hidden='true']" "xpath_element" should exist
-    And "//div[@role='listitem' and contains(.,'Course 1')]//span[@data-region='is-favourite' and @aria-hidden='true']" "xpath_element" should exist
-    And "//div[@role='listitem' and contains(.,'Course 3')]//span[@data-region='is-favourite' and @aria-hidden='true']" "xpath_element" should exist
+    Then "//div[@class='card dashboard-card' and contains(.,'Course 2')]//span[@data-region='is-favourite' and @aria-hidden='false']" "xpath_element" should exist
+    And "//div[@class='card dashboard-card' and contains(.,'Course 2')]//span[@data-region='is-favourite' and @aria-hidden='true']" "xpath_element" should not exist
+    And "//div[@class='card dashboard-card' and contains(.,'Course 2')]//span[@data-region='not-favourite' and @aria-hidden='true']" "xpath_element" should exist
+    And "//div[@class='card dashboard-card' and contains(.,'Course 1')]//span[@data-region='is-favourite' and @aria-hidden='true']" "xpath_element" should exist
+    And "//div[@class='card dashboard-card' and contains(.,'Course 3')]//span[@data-region='is-favourite' and @aria-hidden='true']" "xpath_element" should exist
     And I log out
 
   Scenario: Star a course and switch display to list
diff --git a/blocks/myoverview/tests/behat/block_myoverview_hidden.feature b/blocks/myoverview/tests/behat/block_myoverview_hidden.feature
new file mode 100644 (file)
index 0000000..c9c4845
--- /dev/null
@@ -0,0 +1,83 @@
+@block @block_myoverview @javascript
+Feature: The my overview block allows users to hide their courses
+  In order to enable the my overview block in a course
+  As a student
+  I can add the my overview block to my dashboard
+
+  Background:
+    Given the following "users" exist:
+      | username | firstname | lastname | email                | idnumber |
+      | student1 | Student   | X        | student1@example.com | S1       |
+    And the following "courses" exist:
+      | fullname | shortname | category |
+      | Course 1 | C1        | 0        |
+      | Course 2 | C2        | 0        |
+      | Course 3 | C3        | 0        |
+      | Course 4 | C4        | 0        |
+      | Course 5 | C5        | 0        |
+    And the following "course enrolments" exist:
+      | user | course | role |
+      | student1 | C1 | student |
+      | student1 | C2 | student |
+      | student1 | C3 | student |
+      | student1 | C4 | student |
+      | student1 | C5 | student |
+
+  Scenario: Test hide toggle functionality
+    Given I log in as "student1"
+    When I click on ".coursemenubtn" "css_element" in the "//div[@class='card dashboard-card' and contains(.,'Course 2')]" "xpath_element"
+    And I click on "Hide from view" "link" in the "//div[@class='card dashboard-card' and contains(.,'Course 2')]" "xpath_element"
+    And I reload the page
+    Then I should not see "Course 2" in the "Course overview" "block"
+    And I log out
+
+  Scenario: Test hide toggle functionality w/ favorites
+    Given I log in as "student1"
+    And I click on ".coursemenubtn" "css_element" in the "//div[@class='card dashboard-card' and contains(.,'Course 2')]" "xpath_element"
+    And I click on "Star this course" "link" in the "//div[@class='card dashboard-card' and contains(.,'Course 2')]" "xpath_element"
+    And I click on ".coursemenubtn" "css_element" in the "//div[@class='card dashboard-card' and contains(.,'Course 2')]" "xpath_element"
+    And I click on "Hide from view" "link" in the "//div[@class='card dashboard-card' and contains(.,'Course 2')]" "xpath_element"
+    When I reload the page
+    Then I should not see "Course 2" in the "Course overview" "block"
+    And I click on "All" "button" in the "Course overview" "block"
+    And I click on "Starred" "link" in the "Course overview" "block"
+    Then I should not see "Course 2" in the "Course overview" "block"
+    And I click on "Starred" "button" in the "Course overview" "block"
+    And I click on "Hidden" "link" in the "Course overview" "block"
+    Then I should see "Course 2" in the "Course overview" "block"
+    And I log out
+
+  Scenario: Test show toggle functionality
+    Given I log in as "student1"
+    And I click on ".coursemenubtn" "css_element" in the "//div[@class='card dashboard-card' and contains(.,'Course 2')]" "xpath_element"
+    And I click on "Hide from view" "link" in the "//div[@class='card dashboard-card' and contains(.,'Course 2')]" "xpath_element"
+    When I click on "All" "button" in the "Course overview" "block"
+    And I click on "Hidden" "link" in the "Course overview" "block"
+    When I click on ".coursemenubtn" "css_element" in the "//div[@class='card dashboard-card' and contains(.,'Course 2')]" "xpath_element"
+    And I click on "Show this course" "link" in the "//div[@class='card dashboard-card' and contains(.,'Course 2')]" "xpath_element"
+    And I reload the page
+    And I click on "Hidden" "button" in the "Course overview" "block"
+    When I click on "All" "link" in the "Course overview" "block"
+    Then I should see "Course 2" in the "Course overview" "block"
+    And I log out
+
+  Scenario: Test show toggle functionality w/ favorites
+    Given I log in as "student1"
+    And I click on ".coursemenubtn" "css_element" in the "//div[@class='card dashboard-card' and contains(.,'Course 2')]" "xpath_element"
+    And I click on "Star this course" "link" in the "//div[@class='card dashboard-card' and contains(.,'Course 2')]" "xpath_element"
+    And I click on ".coursemenubtn" "css_element" in the "//div[@class='card dashboard-card' and contains(.,'Course 2')]" "xpath_element"
+    And I click on "Hide from view" "link" in the "//div[@class='card dashboard-card' and contains(.,'Course 2')]" "xpath_element"
+    And I click on "All" "button" in the "Course overview" "block"
+    And I click on "Hidden" "link" in the "Course overview" "block"
+    And I should see "Course 2" in the "Course overview" "block"
+    And I click on ".coursemenubtn" "css_element" in the "//div[@class='card dashboard-card' and contains(.,'Course 2')]" "xpath_element"
+    And I click on "Show this course" "link" in the "//div[@class='card dashboard-card' and contains(.,'Course 2')]" "xpath_element"
+    When I reload the page
+    Then I should not see "Course 2" in the "Course overview" "block"
+    And I click on "Hidden" "button" in the "Course overview" "block"
+    And I click on "All" "link" in the "Course overview" "block"
+    Then I should see "Course 2" in the "Course overview" "block"
+    And I click on "All" "button" in the "Course overview" "block"
+    And I click on "Starred" "link" in the "Course overview" "block"
+    Then I should see "Course 2" in the "Course overview" "block"
+    And I log out
\ No newline at end of file
index 5e2d031..2ccd331 100644 (file)
@@ -78,4 +78,23 @@ class block_myoverview_privacy_testcase extends \core_privacy\tests\provider_tes
             array('block_myoverview_user_view_preference', 'summary')
         );
     }
+
+    public function test_export_user_preferences_with_hidden_courses() {
+        $this->resetAfterTest();
+        $user = $this->getDataGenerator()->create_user();
+        $name = "block_myoverview_hidden_course_1";
+
+        set_user_preference($name, 1, $user);
+        provider::export_user_preferences($user->id);
+        $writer = writer::with_context(\context_system::instance());
+        $blockpreferences = $writer->get_user_preferences('block_myoverview');
+
+        $this->assertEquals(
+            get_string("privacy:request:preference:set", 'block_myoverview', (object) [
+                'name' => $name,
+                'value' => 1,
+            ]),
+            $blockpreferences->{$name}->description
+        );
+    }
 }
\ No newline at end of file
diff --git a/blocks/recentlyaccesseditems/amd/build/main.min.js b/blocks/recentlyaccesseditems/amd/build/main.min.js
new file mode 100644 (file)
index 0000000..d4daa0f
Binary files /dev/null and b/blocks/recentlyaccesseditems/amd/build/main.min.js differ
diff --git a/blocks/recentlyaccesseditems/amd/build/repository.min.js b/blocks/recentlyaccesseditems/amd/build/repository.min.js
new file mode 100644 (file)
index 0000000..fa504bb
Binary files /dev/null and b/blocks/recentlyaccesseditems/amd/build/repository.min.js differ
diff --git a/blocks/recentlyaccesseditems/amd/src/main.js b/blocks/recentlyaccesseditems/amd/src/main.js
new file mode 100644 (file)
index 0000000..5d4fd16
--- /dev/null
@@ -0,0 +1,105 @@
+
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Javascript to initialise the Recently accessed items block.
+ *
+ * @module     block_recentlyaccesseditems/main
+ * @package    block_recentlyaccesseditems
+ * @copyright  2018 Victor Deniz <victor@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+define(
+    [
+        'jquery',
+        'block_recentlyaccesseditems/repository',
+        'core/templates',
+        'core/notification'
+    ],
+    function(
+        $,
+        Repository,
+        Templates,
+        Notification
+    ) {
+
+        var NUM_ITEMS = 9;
+
+        var SELECTORS = {
+            CARDDECK_CONTAINER: '[data-region="recentlyaccesseditems-view"]',
+            CARDDECK: '[data-region="recentlyaccesseditems-view-content"]',
+        };
+
+        /**
+         * Get recent items from backend.
+         *
+         * @method getRecentItems
+         * @param {int} limit Only return this many results
+         * @return {array} Items user most recently has accessed
+         */
+        var getRecentItems = function(limit) {
+            return Repository.getRecentItems(limit);
+        };
+
+        /**
+         * Render the block content.
+         *
+         * @method renderItems
+         * @param {object} root The root element for the items view.
+         * @param {array} items containing array of returned items.
+         * @return {promise} Resolved with HTML and JS strings
+         */
+        var renderItems = function(root, items) {
+            if (items.length > 0) {
+                return Templates.render('block_recentlyaccesseditems/view-cards', {
+                    items: items
+                });
+            } else {
+                var noitemsimgurl = root.attr('data-noitemsimgurl');
+                return Templates.render('block_recentlyaccesseditems/no-items', {
+                    noitemsimgurl: noitemsimgurl
+                });
+            }
+        };
+
+        /**
+         * Get and show the recent items into the block.
+         *
+         * @param {object} root The root element for the items block.
+         */
+        var init = function(root) {
+            root = $(root);
+
+            var itemsContainer = root.find(SELECTORS.CARDDECK_CONTAINER);
+            var itemsContent = root.find(SELECTORS.CARDDECK);
+
+            var itemsPromise = getRecentItems(NUM_ITEMS);
+
+            itemsPromise.then(function(items) {
+                var pageContentPromise = renderItems(itemsContainer, items);
+
+                pageContentPromise.then(function(html, js) {
+                    return Templates.replaceNodeContents(itemsContent, html, js);
+                }).catch(Notification.exception);
+                return itemsPromise;
+            }).catch(Notification.exception);
+        };
+
+        return {
+            init: init
+        };
+    });
\ No newline at end of file
diff --git a/blocks/recentlyaccesseditems/amd/src/repository.js b/blocks/recentlyaccesseditems/amd/src/repository.js
new file mode 100644 (file)
index 0000000..cd76802
--- /dev/null
@@ -0,0 +1,47 @@
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * A javascript module to handle user ajax actions.
+ *
+ * @module     block_recentlyaccesseditems/repository
+ * @package    block_recentlyaccesseditems
+ * @copyright  2018 Victor Deniz <victor@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+define(['core/ajax'], function(Ajax) {
+
+    /**
+     * Get the list of items that the user has most recently accessed.
+     *
+     * @method getRecentItems
+     * @param {int} limit Only return this many results
+     * @return {promise} Resolved with an array of items
+     */
+    var getRecentItems = function(limit) {
+        var args = {};
+        if (typeof limit !== 'undefined') {
+            args.limit = limit;
+        }
+        var request = {
+            methodname: 'block_recentlyaccesseditems_get_recent_items',
+            args: args
+        };
+        return Ajax.call([request])[0];
+    };
+    return {
+        getRecentItems: getRecentItems
+    };
+});
\ No newline at end of file
diff --git a/blocks/recentlyaccesseditems/block_recentlyaccesseditems.php b/blocks/recentlyaccesseditems/block_recentlyaccesseditems.php
new file mode 100644 (file)
index 0000000..d16c303
--- /dev/null
@@ -0,0 +1,65 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+/**
+ * Class definition for the Recently accessed items block.
+ *
+ * @package    block_recentlyaccesseditems
+ * @copyright  2018 Victor Deniz <victor@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+defined('MOODLE_INTERNAL') || die();
+/**
+ * Recently accessed items block class.
+ *
+ * @package    block_recentlyaccesseditems
+ * @copyright  Victor Deniz <victor@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class block_recentlyaccesseditems extends block_base {
+    /**
+     * Initialize class member variables
+     */
+    public function init() {
+        $this->title = get_string('pluginname', 'block_recentlyaccesseditems');
+    }
+
+    /**
+     * Returns the contents.
+     *
+     * @return stdClass contents of block
+     */
+    public function get_content() {
+        if (isset($this->content)) {
+            return $this->content;
+        }
+        $renderable = new block_recentlyaccesseditems\output\main();
+        $renderer = $this->page->get_renderer('block_recentlyaccesseditems');
+
+        $this->content = new stdClass();
+        $this->content->text = $renderer->render($renderable);
+        $this->content->footer = '';
+        return $this->content;
+    }
+
+    /**
+     * Locations where block can be displayed.
+     *
+     * @return array
+     */
+    public function applicable_formats() {
+        return array('my' => true);
+    }
+}
\ No newline at end of file
diff --git a/blocks/recentlyaccesseditems/classes/external.php b/blocks/recentlyaccesseditems/classes/external.php
new file mode 100644 (file)
index 0000000..ddfea0d
--- /dev/null
@@ -0,0 +1,102 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * External API.
+ *
+ * @package    block_recentlyaccesseditems
+ * @copyright  2018 Victor Deniz <victor@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+namespace block_recentlyaccesseditems;
+defined('MOODLE_INTERNAL') || die();
+
+require_once("$CFG->libdir/externallib.php");
+
+use block_recentlyaccesseditems\external\recentlyaccesseditems_item_exporter;
+use external_api;
+use external_function_parameters;
+use external_value;
+use external_multiple_structure;
+use context_user;
+use context_module;
+
+/**
+ * External API class.
+ *
+ * @package    block_recentlyaccesseditems
+ * @copyright  2018 Victor Deniz <victor@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class external extends external_api {
+
+    /**
+     * Returns description of method parameters
+     * @return external_function_parameters
+     */
+    public static function get_recent_items_parameters() {
+        return new external_function_parameters(
+                array(
+                        'limit' => new external_value(PARAM_INT, 'result set limit', VALUE_DEFAULT, 0)
+                )
+        );
+    }
+
+    /**
+     * Get last accessed items by the logged user (activities or resources).
+     *
+     * @param  int $limit Max num of items to return
+     * @return array List of items
+     * @since Moodle 3.6
+     */
+    public static function get_recent_items(int $limit = 0) {
+        global $USER, $PAGE;
+
+        $userid = $USER->id;
+
+        $params = self::validate_parameters(self::get_recent_items_parameters(),
+            array(
+                'limit' => $limit,
+            )
+        );
+
+        $limit = $params['limit'];
+
+        self::validate_context(context_user::instance($userid));
+
+        $items = helper::get_recent_items($limit);
+
+        $renderer = $PAGE->get_renderer('core');
+        $recentitems = array_map(function($item) use ($renderer) {
+            $context = context_module::instance($item->cmid);
+            $exporter = new recentlyaccesseditems_item_exporter($item, ['context' => $context]);
+            return $exporter->export($renderer);
+        }, $items);
+
+        return $recentitems;
+    }
+
+    /**
+     * Returns description of method result value
+     *
+     * @return external_description
+     * @since Moodle 3.6
+     */
+    public static function get_recent_items_returns() {
+        return new external_multiple_structure(recentlyaccesseditems_item_exporter::get_read_structure(),
+                'The most recently accessed activities/resources by the logged user');
+    }
+}
diff --git a/blocks/recentlyaccesseditems/classes/external/recentlyaccesseditems_item_exporter.php b/blocks/recentlyaccesseditems/classes/external/recentlyaccesseditems_item_exporter.php
new file mode 100644 (file)
index 0000000..f26b43d
--- /dev/null
@@ -0,0 +1,114 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+/**
+ * Class for exporting the data needed to render a recent accessed item.
+ *
+ * @package    block_recentlyaccesseditems
+ * @copyright  2018 Victor Deniz <victor@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+namespace block_recentlyaccesseditems\external;
+defined('MOODLE_INTERNAL') || die();
+
+use renderer_base;
+use moodle_url;
+
+/**
+ * Class for exporting the data needed to render a recent accessed item.
+ *
+ * @copyright  2018 Victor Deniz
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class recentlyaccesseditems_item_exporter extends \core\external\exporter {
+    /**
+     * Returns a list of objects that are related to this persistent.
+     *
+     */
+    protected static function define_related() {
+        // We cache the context so it does not need to be retrieved from the course.
+        return array('context' => '\\context');
+    }
+
+    /**
+     * Get the additional values to inject while exporting.
+     *
+     * @param renderer_base $output The renderer
+     * @return array Additional properties with values
+     */
+    protected function get_other_values(renderer_base $output) {
+        global $OUTPUT;
+
+        return array(
+                'viewurl' => (new moodle_url('/mod/'.$this->data->modname.'/view.php',
+                        array('id' => $this->data->cmid)))->out(false),
+                'courseviewurl' => (new moodle_url('/course/view.php', array('id' => $this->data->courseid)))->out(false),
+                'icon' => $OUTPUT->image_icon('icon', get_string('pluginname', $this->data->modname), $this->data->modname)
+        );
+    }
+
+    /**
+     * Return the list of properties.
+     *
+     * @return array Properties.
+     */
+    public static function define_properties() {
+        return array(
+            'id' => array(
+                'type' => PARAM_INT,
+            ),
+            'courseid' => array(
+                'type' => PARAM_INT,
+            ),
+            'cmid' => array(
+                'type' => PARAM_INT,
+            ),
+            'userid' => array(
+                'type' => PARAM_INT,
+            ),
+            'modname' => array(
+                'type' => PARAM_TEXT,
+            ),
+            'name' => array(
+                    'type' => PARAM_TEXT,
+            ),
+            'coursename' => array(
+                'type' => PARAM_TEXT,
+            ),
+            'timeaccess' => array(
+                'type' => PARAM_INT,
+            )
+        );
+    }
+
+    /**
+     * Return the list of additional properties.
+     *
+     * @return array Additional properties.
+     */
+    public static function define_other_properties() {
+        return array(
+            'viewurl' => array(
+                'type' => PARAM_TEXT,
+            ),
+            'courseviewurl' => array(
+                    'type' => PARAM_URL,
+            ),
+            'icon' => array(
+                'type' => PARAM_RAW,
+            )
+        );
+    }
+}
\ No newline at end of file
diff --git a/blocks/recentlyaccesseditems/classes/helper.php b/blocks/recentlyaccesseditems/classes/helper.php
new file mode 100644 (file)
index 0000000..e7e204f
--- /dev/null
@@ -0,0 +1,93 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Recently accessed items helper.
+ *
+ * @package    block_recentlyaccesseditems
+ * @copyright  2018 Victor Deniz <victor@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace block_recentlyaccesseditems;
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Recently accessed items helper.
+ *
+ * @package    block_recentlyaccesseditems
+ * @copyright  2018 Victor Deniz <victor@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class helper {
+    /**
+     * Returns a list of the most recently items accessed by the logged user
+     *
+     * @param int $limit Restrict result set to this amount
+     * @return array List of recent items accessed by userid
+     */
+    public static function get_recent_items(int $limit = 0) {
+        global $USER, $DB;
+
+        $userid = $USER->id;
+
+        $courses = array();
+        $recentitems = array();
+
+        if (!isloggedin() or \core\session\manager::is_loggedinas() or isguestuser()) {
+            // No access tracking.
+            return $recentitems;
+        }
+
+        // Determine sort sql clause.
+        $sort = 'timeaccess DESC';
+
+        $paramsql = array('userid' => $userid);
+        $records = $DB->get_records('block_recentlyaccesseditems', $paramsql, $sort);
+        $order = 0;
+
+        // Get array of items by course. Use $order index to keep sql sorted results.
+        foreach ($records as $record) {
+            $courses[$record->courseid][$order++] = $record;
+        }
+
+        // Group by courses to reduce get_fast_modinfo requests.
+        foreach ($courses as $key => $items) {
+            $modinfo = get_fast_modinfo($key);
+            foreach ($items as $key => $item) {
+                // Exclude not visible items.
+                if (!$modinfo->cms[$item->cmid]->uservisible) {
+                    continue;
+                }
+                $item->modname = $modinfo->cms[$item->cmid]->modname;
+                $item->name = $modinfo->cms[$item->cmid]->name;
+                $item->coursename = get_course_display_name_for_list($modinfo->get_course());
+                $recentitems[$key] = $item;
+            }
+        }
+
+        ksort($recentitems);
+
+        // Apply limit.
+        if (!$limit) {
+            $limit = count($recentitems);
+        }
+        $recentitems = array_slice($recentitems, 0, $limit);
+
+        return $recentitems;
+    }
+}
\ No newline at end of file
diff --git a/blocks/recentlyaccesseditems/classes/observer.php b/blocks/recentlyaccesseditems/classes/observer.php
new file mode 100644 (file)
index 0000000..700ced8
--- /dev/null
@@ -0,0 +1,105 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Event observer.
+ *
+ * @package    block_recentlyaccesseditems
+ * @copyright  2018 Victor Deniz
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace block_recentlyaccesseditems;
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Events observer.
+ *
+ * Stores all actions about modules viewed in block_recentlyaccesseditems table.
+ *
+ * @package    block_recentlyaccesseditems
+ * @copyright  2018 Victor Deniz <victor@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class observer {
+
+    /**
+     * @var string Block table name.
+     */
+    private static $table = 'block_recentlyaccesseditems';
+
+    /**
+     * Register items views in block_recentlyaccesseditems table.
+     *
+     * When the item is view for the first time, a new record is created. If the item was viewed before, the time is
+     * updated.
+     *
+     * @param \core\event\base $event
+     */
+    public static function store(\core\event\base $event) {
+        global $DB;
+
+        if (!isloggedin() or \core\session\manager::is_loggedinas() or isguestuser()) {
+            // No access tracking.
+            return;
+        }
+
+        $conditions = [
+            'userid' => $event->userid
+        ];
+
+        $records = $DB->get_records(self::$table, $conditions, "timeaccess DESC");
+
+        foreach ($records as $record) {
+            if (($record->userid == $event->userid) && ($record->cmid == $event->contextinstanceid)) {
+                $conditions = [
+                        'userid' => $event->userid,
+                        'cmid' => $event->contextinstanceid
+                ];
+                $DB->set_field(self::$table, 'timeaccess', $event->timecreated, $conditions);
+                return;
+            }
+        }
+
+        if (count($records) >= 9) {
+            $conditions = [
+                    'id' => end($records)->id,
+            ];
+            $DB->delete_records(self::$table, $conditions);
+        }
+
+        $eventdata = new \stdClass();
+
+        $eventdata->cmid = $event->contextinstanceid;
+        $eventdata->timeaccess = $event->timecreated;
+        $eventdata->courseid = $event->courseid;
+        $eventdata->userid = $event->userid;
+
+        $DB->insert_record(self::$table, $eventdata);
+    }
+
+    /**
+     * Remove record when course module is deleted.
+     *
+     * @param \core\event\base $event
+     */
+    public static function remove(\core\event\base $event) {
+        global $DB;
+
+        $DB->delete_records(self::$table, array('cmid' => $event->contextinstanceid));
+    }
+}
\ No newline at end of file
diff --git a/blocks/recentlyaccesseditems/classes/output/main.php b/blocks/recentlyaccesseditems/classes/output/main.php
new file mode 100644 (file)
index 0000000..2774196
--- /dev/null
@@ -0,0 +1,53 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+/**
+ * Class containing data for Recently accessed items block.
+ *
+ * @package    block_recentlyaccesseditems
+ * @copyright  2018 Victor Deniz <victor@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace block_recentlyaccesseditems\output;
+defined('MOODLE_INTERNAL') || die();
+
+use renderable;
+use renderer_base;
+use templatable;
+
+/**
+ * Class containing data for Recently accessed items block.
+ *
+ * @package    block_recentlyaccesseditems
+ * @copyright  2018 Victor Deniz <victor@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class main implements renderable, templatable {
+    /**
+     * Export this data so it can be used as the context for a mustache template.
+     *
+     * @param \renderer_base $output
+     * @return stdClass
+     */
+    public function export_for_template(renderer_base $output) {
+
+        $noitemsimgurl = $output->image_url('items', 'block_recentlyaccesseditems')->out();
+
+        return [
+            'noitemsimgurl' => $noitemsimgurl
+        ];
+    }
+}
\ No newline at end of file
diff --git a/blocks/recentlyaccesseditems/classes/output/renderer.php b/blocks/recentlyaccesseditems/classes/output/renderer.php
new file mode 100644 (file)
index 0000000..ad69b66
--- /dev/null
@@ -0,0 +1,46 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+/**
+ * Recently accessed items block renderer
+ *
+ * @package    block_recentlyaccesseditems
+ * @copyright  2018 Victor Deniz <victor@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+namespace block_recentlyaccesseditems\output;
+
+defined('MOODLE_INTERNAL') || die;
+
+use plugin_renderer_base;
+
+/**
+ * Recently accessed items block renderer
+ *
+ * @package    block_recentlyaccesseditems
+ * @copyright  2018 Victor Deniz <victor@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class renderer extends plugin_renderer_base {
+    /**
+     * Return the main content for the Recently accessed items block.
+     *
+     * @param \renderer_base $main The main renderable
+     * @return string HTML string
+     */
+    public function render_recentlyaccesseditems(renderer_base $main) {
+        return $this->render_from_template('block_recentlyaccesseditems/main', $main->export_for_template($this));
+    }
+}
\ No newline at end of file
diff --git a/blocks/recentlyaccesseditems/classes/privacy/provider.php b/blocks/recentlyaccesseditems/classes/privacy/provider.php
new file mode 100644 (file)
index 0000000..f56589b
--- /dev/null
@@ -0,0 +1,151 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Privacy Subsystem implementation for Recently accessed items block.
+ *
+ * @package    block_recentlyaccesseditems
+ * @copyright  2018 Victor Deniz <victor@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace block_recentlyaccesseditems\privacy;
+
+defined('MOODLE_INTERNAL') || die();
+
+use \core_privacy\local\metadata\collection;
+use \core_privacy\local\request\transform;
+use \core_privacy\local\request\contextlist;
+use \core_privacy\local\request\approved_contextlist;
+use \core_privacy\local\request\writer;
+
+/**
+ * Privacy Subsystem for block_recentlyaccesseditems.
+ *
+ * @package    block_recentlyaccesseditems
+ * @copyright  2018 Victor Deniz <victor@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class provider implements \core_privacy\local\metadata\provider, \core_privacy\local\request\subsystem\provider {
+
+    /**
+     * Returns information about the user data stored in this component.
+     *
+     * @param  collection $collection A list of information about this component
+     * @return collection The collection object filled out with information about this component.
+     */
+    public static function get_metadata(collection $collection) : collection {
+        $recentitems = [
+                'userid' => 'privacy:metadata:userid',
+                'courseid' => 'privacy:metadata:courseid',
+                'cmid' => 'privacy:metadata:cmid',
+                'timeaccess' => 'privacy:metadata:timeaccess'
+        ];
+
+        $collection->add_database_table('block_recentlyaccesseditems', $recentitems,
+                'privacy:metadata:block_recentlyaccesseditemstablesummary');
+
+        return $collection;
+    }
+
+    /**
+     * Get the list of contexts that contain user information for the specified user.
+     *
+     * @param   int $userid The user to search.
+     * @return  contextlist $contextlist The contextlist containing the list of contexts used in this plugin.
+     */
+    public static function get_contexts_for_userid(int $userid) : contextlist {
+        $params = ['userid' => $userid, 'contextuser' => CONTEXT_USER];
+        $sql = "SELECT id
+                  FROM {context}
+                 WHERE instanceid = :userid and contextlevel = :contextuser";
+        $contextlist = new contextlist();
+        $contextlist->add_from_sql($sql, $params);
+        return $contextlist;
+    }
+
+    /**
+     * Export all user data for the specified user, in the specified contexts.
+     *
+     * @param approved_contextlist $contextlist The approved contexts to export information for.
+     */
+    public static function export_user_data(approved_contextlist $contextlist) {
+        $context = $contextlist->current();
+        $user = \core_user::get_user($contextlist->get_user()->id);
+        static::export_recentitems($user->id, $context);
+    }
+
+    /**
+     * Export information about the most recently accessed items.
+     *
+     * @param  int $userid The user ID.
+     * @param  \context $context The user context.
+     */
+    protected static function export_recentitems(int $userid, \context $context) {
+        global $DB;
+        $sql = "SELECT ra.id, c.fullname, ra.timeaccess, m.name, ra.cmid
+                  FROM {block_recentlyaccesseditems} ra
+                  JOIN {course} c ON c.id = ra.courseid
+                  JOIN {course_modules} cm on cm.id = ra.cmid
+                  JOIN {modules} m ON m.id = cm.module
+                 WHERE ra.userid = :userid";
+
+        $params = ['userid' => $userid];
+        $records = $DB->get_records_sql($sql, $params);
+        if (!empty($records)) {
+            $recentitems = (object) array_map(function($record) use($context) {
+                return [
+                        'course_name' => format_string($record->fullname, true, ['context' => $context]),
+                        'module_name' => format_string($record->name),
+                        'timeaccess' => transform::datetime($record->timeaccess)
+                ];
+            }, $records);
+            writer::with_context($context)->export_data([get_string('privacy:recentlyaccesseditemspath',
+                    'block_recentlyaccesseditems')], $recentitems);
+        }
+    }
+
+    /**
+     * Delete all data for all users in the specified context.
+     *
+     * @param context $context The specific context to delete data for.
+     */
+    public static function delete_data_for_all_users_in_context(\context $context) {
+        global $DB;
+
+        // Only delete data for a user context.
+        if ($context->contextlevel == CONTEXT_USER) {
+            // Delete recent items access.
+            $DB->delete_records('block_recentlyaccesseditems', ['userid' => $context->instanceid]);
+        }
+    }
+
+    /**
+     * Delete all user data for the specified user, in the specified contexts.
+     *
+     * @param approved_contextlist $contextlist The approved contexts and user information to delete information for.
+     */
+    public static function delete_data_for_user(approved_contextlist $contextlist) {
+        global $DB;
+
+        foreach ($contextlist as $context) {
+            // Let's be super certain that we have the right information for this user here.
+            if ($context->contextlevel == CONTEXT_USER && $contextlist->get_user()->id == $context->instanceid) {
+                $DB->delete_records('block_recentlyaccesseditems', ['userid' => $context->instanceid]);
+            }
+        }
+    }
+}
\ No newline at end of file
diff --git a/blocks/recentlyaccesseditems/db/access.php b/blocks/recentlyaccesseditems/db/access.php
new file mode 100644 (file)
index 0000000..99ef6db
--- /dev/null
@@ -0,0 +1,41 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+/**
+ * Capabilities for the Recently accessed items block.
+ *
+ * @package    block_recentlyaccesseditems
+ * @copyright  2018 Victor Deniz <victor@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+defined('MOODLE_INTERNAL') || die();
+$capabilities = array(
+        'block/recentlyaccesseditems:myaddinstance' => array(
+                'captype' => 'write',
+                'contextlevel' => CONTEXT_SYSTEM,
+                'archetypes' => array(
+                        'user' => CAP_ALLOW
+                ),
+                'clonepermissionsfrom' => 'moodle/my:manageblocks'
+        ),
+        'block/recentlyaccesseditems:addinstance' => array(
+                'captype' => 'write',
+                'contextlevel' => CONTEXT_BLOCK,
+                'archetypes' => array(
+                        'manager' => CAP_ALLOW
+                ),
+                'clonepermissionsfrom' => 'moodle/site:manageblocks'
+        )
+);
\ No newline at end of file
diff --git a/blocks/recentlyaccesseditems/db/events.php b/blocks/recentlyaccesseditems/db/events.php
new file mode 100644 (file)
index 0000000..8f08ced
--- /dev/null
@@ -0,0 +1,37 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Recently accessed items event observer.
+ *
+ * @package   block_recentlyaccesseditems
+ * @category  event
+ * @copyright 2018 Victor Deniz <victor@moodle.com>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+$observers = array (
+    array(
+            'eventname'   => '\core\event\course_module_viewed',
+            'callback'    => 'block_recentlyaccesseditems\observer::store',
+    ),
+    array(
+            'eventname'   => '\core\event\course_module_deleted',
+            'callback'    => 'block_recentlyaccesseditems\observer::remove'
+    ),
+);
\ No newline at end of file
diff --git a/blocks/recentlyaccesseditems/db/install.xml b/blocks/recentlyaccesseditems/db/install.xml
new file mode 100644 (file)
index 0000000..2b6bf10
--- /dev/null
@@ -0,0 +1,26 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<XMLDB PATH="blocks/recentlyaccesseditems/db" VERSION="20181030" COMMENT="XMLDB file for Moodle blocks/recentlyaccesseditems"
+    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+    xsi:noNamespaceSchemaLocation="../../../lib/xmldb/xmldb.xsd"
+>
+  <TABLES>
+    <TABLE NAME="block_recentlyaccesseditems" COMMENT="Most recently accessed items accessed by a user">
+      <FIELDS>
+        <FIELD NAME="id" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="true"/>
+        <FIELD NAME="courseid" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="false" COMMENT="Course id the item belongs to"/>
+        <FIELD NAME="cmid" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="false" COMMENT="Item course module id"/>
+        <FIELD NAME="userid" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="false" COMMENT="User id that accessed the item"/>
+        <FIELD NAME="timeaccess" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="false" COMMENT="Time the user accessed the last time an item"/>
+      </FIELDS>
+      <KEYS>
+        <KEY NAME="primary" TYPE="primary" FIELDS="id"/>
+        <KEY NAME="userid" TYPE="foreign" FIELDS="userid" REFTABLE="user" REFFIELDS="id"/>
+        <KEY NAME="courseid" TYPE="foreign" FIELDS="courseid" REFTABLE="course" REFFIELDS="id"/>
+        <KEY NAME="cmid" TYPE="foreign" FIELDS="cmid" REFTABLE="course_modules" REFFIELDS="id"/>
+      </KEYS>
+      <INDEXES>
+        <INDEX NAME="userid-courseid-cmid" UNIQUE="true" FIELDS="userid, courseid, cmid"/>
+      </INDEXES>
+    </TABLE>
+  </TABLES>
+</XMLDB>
\ No newline at end of file
diff --git a/blocks/recentlyaccesseditems/db/services.php b/blocks/recentlyaccesseditems/db/services.php
new file mode 100644 (file)
index 0000000..6caf09f
--- /dev/null
@@ -0,0 +1,38 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Web service for Recently accessed items block
+ *
+ * @package    block_recentlyaccesseditems
+ * @since      Moodle 3.6
+ * @copyright  2018 Victor Deniz <victor@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+$functions = array(
+    'block_recentlyaccesseditems_get_recent_items' => array(
+        'classname' => 'block_recentlyaccesseditems\external',
+        'methodname' => 'get_recent_items',
+        'classpath' => '',
+        'description' => 'List of items a user has accessed most recently.',
+        'type' => 'read',
+        'ajax' => true,
+        'services' => array(MOODLE_OFFICIAL_MOBILE_SERVICE),
+    ),
+);
\ No newline at end of file
diff --git a/blocks/recentlyaccesseditems/lang/en/block_recentlyaccesseditems.php b/blocks/recentlyaccesseditems/lang/en/block_recentlyaccesseditems.php
new file mode 100644 (file)
index 0000000..d9238f5
--- /dev/null
@@ -0,0 +1,32 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+/**
+ * Strings for the Recently accessed items block.
+ *
+ * @package    block_recentlyaccesseditems
+ * @copyright  2018 Victor Deniz <victor@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+$string['noitems'] = 'No recent items';
+$string['pluginname'] = 'Recently accessed items';
+$string['privacy:metadata:cmid'] = 'The course module id';
+$string['privacy:metadata:courseid'] = 'Course the item belongs to';
+$string['privacy:metadata:block_recentlyaccesseditemstablesummary'] = 'Information about the last time a user accessed an item.';
+$string['privacy:metadata:timeaccess'] = 'The time of the last access to the item by the user.';
+$string['privacy:metadata:userid'] = 'User who accessed the item';
+$string['privacy:recentlyaccesseditemspath'] = 'Recently accessed items';
+$string['recentlyaccesseditems:addinstance'] = 'Add a new recently accessed items block';
+$string['recentlyaccesseditems:myaddinstance'] = 'Add a new recently accessed items block to Dashboard';
\ No newline at end of file
diff --git a/blocks/recentlyaccesseditems/pix/items.svg b/blocks/recentlyaccesseditems/pix/items.svg
new file mode 100644 (file)
index 0000000..ed7546a
--- /dev/null
@@ -0,0 +1,41 @@
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="157 -1509 148 125" preserveAspectRatio="xMinYMid meet">
+  <defs>
+    <style>
+      .cls-1 {
+        clip-path: url(#clip-Activities);
+      }
+
+      .cls-2 {
+        fill: #eee;
+      }
+
+      .cls-3 {
+        fill: #c4c8cc;
+      }
+
+      .cls-4 {
+        fill: #fff;
+      }
+    </style>
+    <clipPath id="clip-Activities">
+      <rect x="157" y="-1509" width="148" height="125"/>
+    </clipPath>
+  </defs>
+  <g id="Activities" class="cls-1">
+    <g id="Group_42" data-name="Group 42" transform="translate(-268 -1985)">
+      <ellipse id="Ellipse_37" data-name="Ellipse 37" class="cls-2" cx="74" cy="14.785" rx="74" ry="14.785" transform="translate(425 571.43)"/>
+      <rect id="Rectangle_80" data-name="Rectangle 80" class="cls-3" width="94.182" height="110.215" transform="translate(451.909 476)"/>
+      <g id="Group_41" data-name="Group 41" transform="translate(467.043 493)">
+        <rect id="Rectangle_81" data-name="Rectangle 81" class="cls-4" width="44.456" height="5.625" transform="translate(21.16 0.549)"/>
+        <rect id="Rectangle_82" data-name="Rectangle 82" class="cls-4" width="33.342" height="5.625" transform="translate(21.16 11.652)"/>
+        <rect id="Rectangle_83" data-name="Rectangle 83" class="cls-4" width="44.456" height="5.625" transform="translate(21.16 30.772)"/>
+        <rect id="Rectangle_84" data-name="Rectangle 84" class="cls-4" width="33.342" height="5.625" transform="translate(21.16 41.875)"/>
+        <rect id="Rectangle_85" data-name="Rectangle 85" class="cls-4" width="44.456" height="5.625" transform="translate(21.16 61.291)"/>
+        <rect id="Rectangle_86" data-name="Rectangle 86" class="cls-4" width="33.342" height="5.625" transform="translate(21.16 72.393)"/>
+        <ellipse id="Ellipse_38" data-name="Ellipse 38" class="cls-4" cx="7.007" cy="7" rx="7.007" ry="7" transform="translate(0 0)"/>
+        <ellipse id="Ellipse_39" data-name="Ellipse 39" class="cls-4" cx="7.007" cy="7" rx="7.007" ry="7" transform="translate(0 31)"/>
+        <ellipse id="Ellipse_40" data-name="Ellipse 40" class="cls-4" cx="7.007" cy="7" rx="7.007" ry="7" transform="translate(0 61)"/>
+      </g>
+    </g>
+  </g>
+</svg>
diff --git a/blocks/recentlyaccesseditems/templates/main.mustache b/blocks/recentlyaccesseditems/templates/main.mustache
new file mode 100644 (file)
index 0000000..2f2ce75
--- /dev/null
@@ -0,0 +1,45 @@
+{{!
+    This file is part of Moodle - http://moodle.org/
+
+    Moodle is free software: you can redistribute it and/or modify
+    it under the terms of the GNU General Public License as published by
+    the Free Software Foundation, either version 3 of the License, or
+    (at your option) any later version.
+
+    Moodle is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+    GNU General Public License for more details.
+
+    You should have received a copy of the GNU General Public License
+    along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+}}
+{{!
+    @template block_recentlyaccesseditems/main
+
+    This template renders the main content area for the Recently accessed itemes block.
+
+    Example context (json):
+    {}
+}}
+
+<div id="block-recentlyaccesseditems-{{uniqid}}" class="block-  recentlyaccesseditems" data-region="recentlyaccesseditems">
+    <div class="container-fluid p-0">
+        {{> block_recentlyaccesseditems/recentlyaccesseditems-view }}
+    </div>
+</div>
+{{#js}}
+require(
+[
+    'jquery',
+    'block_recentlyaccesseditems/main',
+],
+function(
+    $,
+    Main
+) {
+    var root = $('#block-recentlyaccesseditems-{{uniqid}}');
+
+    Main.init(root);
+});
+{{/js}}
\ No newline at end of file
diff --git a/blocks/recentlyaccesseditems/templates/no-items.mustache b/blocks/recentlyaccesseditems/templates/no-items.mustache
new file mode 100644 (file)
index 0000000..794f424
--- /dev/null
@@ -0,0 +1,34 @@
+{{!
+    This file is part of Moodle - http://moodle.org/
+
+    Moodle is free software: you can redistribute it and/or modify
+    it under the terms of the GNU General Public License as published by
+    the Free Software Foundation, either version 3 of the License, or
+    (at your option) any later version.
+
+    Moodle is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+    GNU General Public License for more details.
+
+    You should have received a copy of the GNU General Public License
+    along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+}}
+{{!
+    @template block_recentlyaccesseditems/no-items
+
+    This template renders the no items message.
+
+    Example context (json):
+    {
+        "noitemsimgurl": "https://moodlesite/theme/image.php/boost/block_recentlyaccesseditems/1535727318/items"
+    }
+}}
+<div class="text-center mt-3" data-region="empty-message">
+    <img class="empty-placeholder-image-lg"
+        src="{{noitemsimgurl}}"
+        alt="{{#str}} noitems, block_recentlyaccesseditems {{/str}}"
+        role="presentation"
+    >
+    <p class="text-muted mt-3">{{#str}} noitems, block_recentlyaccesseditems {{/str}}</p>
+</div>
\ No newline at end of file
diff --git a/blocks/recentlyaccesseditems/templates/placeholder-item.mustache b/blocks/recentlyaccesseditems/templates/placeholder-item.mustache
new file mode 100644 (file)
index 0000000..e1bf633
--- /dev/null
@@ -0,0 +1,33 @@
+{{!
+    This file is part of Moodle - http://moodle.org/
+
+    Moodle is free software: you can redistribute it and/or modify
+    it under the terms of the GNU General Public License as published by
+    the Free Software Foundation, either version 3 of the License, or
+    (at your option) any later version.
+
+    Moodle is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+    GNU General Public License for more details.
+
+    You should have received a copy of the GNU General Public License
+    along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+}}
+{{!
+    @template block_recentlyaccesseditems/placeholder-item
+
+    This template renders a card item loading placeholder for the Recently accessed items block.
+
+    Example context (json):
+    {}
+}}
+<div class="card-body course-info-container">
+    <div class="d-flex flex-row align-items-center" style="height: 32px">
+        <div class="bg-pulse-grey rounded-circle" style="height: 32px; width: 32px;"></div>
+        <div class="w-100 line-height-3 ml-1">
+                <div class="bg-pulse-grey w-100" style="height: 15px;"></div>
+                <div class="bg-pulse-grey w-75 mt-1" style="height: 10px;"></div>
+        </div>
+    </div>
+</div>
diff --git a/blocks/recentlyaccesseditems/templates/recentlyaccesseditems-view.mustache b/blocks/recentlyaccesseditems/templates/recentlyaccesseditems-view.mustache
new file mode 100644 (file)
index 0000000..6e4494f
--- /dev/null
@@ -0,0 +1,40 @@
+{{!
+    This file is part of Moodle - http://moodle.org/
+
+    Moodle is free software: you can redistribute it and/or modify
+    it under the terms of the GNU General Public License as published by
+    the Free Software Foundation, either version 3 of the License, or
+    (at your option) any later version.
+
+    Moodle is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+    GNU General Public License for more details.
+
+    You should have received a copy of the GNU General Public License
+    along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+}}
+{{!
+    @template block_recentlyaccesseditems/recentlyaccesseditems-view
+
+    This template renders the items view for the Recently accessed items block.
+
+    Example context (json):
+    {
+        "noitemsimgurl": "https://moodlesite/theme/image.php/boost/block_recentactivities/1535727318/items"
+    }
+}}
+<div id="recentlyaccesseditems-view-{{uniqid}}"
+     data-region="recentlyaccesseditems-view"
+     data-noitemsimgurl="{{noitemsimgurl}}">
+    <div data-region="recentlyaccesseditems-view-content">
+        <div data-region="recentlyaccesseditems-loading-placeholder">
+            <div class="card-deck dashboard-card-deck">
+                {{> block_recentlyaccesseditems/placeholder-item }}
+                {{> block_recentlyaccesseditems/placeholder-item }}
+                {{> block_recentlyaccesseditems/placeholder-item }}
+            </div>
+        </div>
+    </div>
+</div>
+
diff --git a/blocks/recentlyaccesseditems/templates/view-cards.mustache b/blocks/recentlyaccesseditems/templates/view-cards.mustache
new file mode 100644 (file)
index 0000000..8b3619c
--- /dev/null
@@ -0,0 +1,63 @@
+{{!
+    This file is part of Moodle - http://moodle.org/
+
+    Moodle is free software: you can redistribute it and/or modify
+    it under the terms of the GNU General Public License as published by
+    the Free Software Foundation, either version 3 of the License, or
+    (at your option) any later version.
+
+    Moodle is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+    GNU General Public License for more details.
+
+    You should have received a copy of the GNU General Public Licensebllsdsadfasfd
+    along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+}}
+{{!
+    @template block_recentlyaccesseditems/view-cards
+
+    This template renders the items cards of the Recently accessed items block.
+
+    Example context (json):
+    {
+        "items": [
+            {
+                "cmid": 64,
+                "courseid": 2,
+                "coursename": "Course",
+                "courseviewurl": "https://moodlesite/course/view.php?id=2",
+                "icon": "<img class=\"icon\" alt=\"Forum\" title=\"Forum\" src=\"http://moodlesite/theme/image.php/boost/forum/1539858121/icon\" />",
+                "name": "Assignment due 1",
+                "id": 17,
+                "modname": "Forum",
+                "name": "Forum",
+                "timeaccess": 1539848498,
+                "userid": 2,
+                "viewurl": "http://moodlesite/mod/forum?id=64"
+            }
+        ]
+    }
+}}
+
+<div class="card-deck dashboard-card-deck" role="list">
+    {{#items}}
+        <a
+                class="card dashboard-card py-2 pl-0 pr-0"
+                href="{{{viewurl}}}"
+                title="{{name}}"
+        >
+            <div class="card-body course-info-container">
+                <div class="d-flex">
+                    <div class="icon-size-4 d-flex align-self-center">
+                        {{{icon}}}
+                    </div>
+                    <div class="w-100 line-height-3 text-truncate">
+                        <h6 class="mb-0">{{{name}}}</h6>
+                        <small class="text-muted m-b-0">{{{coursename}}}</small>
+                    </div>
+                </div>
+            </div>
+        </a>
+    {{/items}}
+</div>
\ No newline at end of file
diff --git a/blocks/recentlyaccesseditems/tests/behat/block_recentlyaccesseditems_dashboard.feature b/blocks/recentlyaccesseditems/tests/behat/block_recentlyaccesseditems_dashboard.feature
new file mode 100644 (file)
index 0000000..5aa383f
--- /dev/null
@@ -0,0 +1,38 @@
+@block @block_recentlyaccesseditems @javascript
+Feature: The recently accessed items block allows users to easily access their most recently visited items
+  In order to access the most recent items accessed
+  As a user
+  I can use the recently accessed items block in my dashboard
+
+  Background:
+    Given the following "users" exist:
+      | username | firstname | lastname | email                |
+      | student1 | Student   | 1        | student1@example.com |
+    And the following "courses" exist:
+      | fullname | shortname |
+      | Course 1 | C1        |
+      | Course 2 | C2        |
+    And the following "course enrolments" exist:
+      | user     | course | role    |
+      | student1 | C1     | student |
+      | student1 | C2     | student |
+    And I log in as "admin"
+    And I am on "Course 1" course homepage
+    And I turn editing mode on
+    And I add a "Forum" to section "1" and I fill the form with:
+      | Forum name | Test forum name |
+      | Forum type | Standard forum for general use |
+      | Description | Test forum description |
+    And I log out
+    And I log in as "student1"
+    When I press "Customise this page"
+    And I add the "Recently accessed items" block
+
+  Scenario: User has not accessed any item
+    Then I should see "No recent items" in the "Recently accessed items" "block"
+
+  Scenario: User has accessed some items
+    Given I am on "Course 1" course homepage
+    When  I follow "Test forum name"
+    And I follow "Dashboard" in the user menu
+    Then I should see "Test forum name" in the "Recently accessed items" "block"
\ No newline at end of file
diff --git a/blocks/recentlyaccesseditems/tests/externallib_test.php b/blocks/recentlyaccesseditems/tests/externallib_test.php
new file mode 100644 (file)
index 0000000..62de36a
--- /dev/null
@@ -0,0 +1,107 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * External block functions unit tests
+ *
+ * @package    block_recentlyaccesseditems
+ * @category   external
+ * @copyright  2018 Victor Deniz <victor@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ * @since      Moodle 3.6
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+global $CFG;
+
+require_once($CFG->dirroot . '/webservice/tests/helpers.php');
+
+/**
+ * Test Recently accessed items block external functions
+ *
+ * @package    block_recentlyaccesseditems
+ * @category   external
+ * @copyright  2018 Victor Deniz <victor@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ * @since      Moodle 3.6
+ */
+class block_recentlyaccesseditems_externallib_testcase extends externallib_advanced_testcase {
+
+    /**
+     * Test the get_recent_items function.
+     */
+    public function test_get_recent_items() {
+
+        $this->resetAfterTest();
+        $generator = $this->getDataGenerator();
+
+        // Add courses.
+        $courses = array();
+        for ($i = 1; $i < 4; $i++) {
+            $courses[] = $generator->create_course();
+        };
+
+        // Add users.
+        $student = $generator->create_user();
+        $teacher = $generator->create_user();
+
+        // Enrol users and add items to courses.
+        foreach ($courses as $course) {
+            $generator->enrol_user($student->id, $course->id, 'student');
+            $forum[] = $this->getDataGenerator()->create_module('forum', array('course' => $course));
+            $glossary[] = $this->getDataGenerator()->create_module('glossary', array('course' => $course));
+            $chat[] = $this->getDataGenerator()->create_module('chat', array('course' => $course));
+        }
+        $generator->enrol_user($teacher->id, $courses[0]->id, 'teacher');
+
+        $this->setUser($student);
+
+        // No recent items.
+        $result = \block_recentlyaccesseditems\external::get_recent_items();
+        $this->assertCount(0, $result);
+
+        // Student access all forums.
+        foreach ($forum as $module) {
+            $event = \mod_forum\event\course_module_viewed::create(array('context' => context_module::instance($module->cmid),
+                    'objectid' => $module->id));
+            $event->trigger();
+            $this->waitForSecond();
+        }
+
+        // Test that only access to forums are returned.
+        $result = \block_recentlyaccesseditems\external::get_recent_items();
+        $this->assertCount(count($forum), $result);
+
+        // Student access all assignments.
+        foreach ($chat as $module) {
+            $event = \mod_chat\event\course_module_viewed::create(array('context' => context_module::instance($module->cmid),
+                    'objectid' => $module->id));
+            $event->trigger();
+            $this->waitForSecond();
+        }
+
+        // Test that results are sorted by timeaccess DESC (default).
+        $result = \block_recentlyaccesseditems\external::get_recent_items();
+        $this->assertCount((count($forum) + count($chat)), $result);
+        foreach ($result as $key => $record) {
+            if ($key == 0) {
+                continue;
+            }
+            $this->assertTrue($record->timeaccess < $result[$key - 1]->timeaccess);
+        }
+    }
+}
\ No newline at end of file
diff --git a/blocks/recentlyaccesseditems/tests/observer_test.php b/blocks/recentlyaccesseditems/tests/observer_test.php
new file mode 100644 (file)
index 0000000..fcc0eb2
--- /dev/null
@@ -0,0 +1,160 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Block recentlyaccesseditems observer tests.
+ *
+ * @package    block_recentlyaccesseditems
+ * @copyright  2018 Victor Deniz <victor@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ * @since      Moodle 3.6
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+global $CFG;
+require_once($CFG->dirroot . '/mod/assign/tests/generator.php');
+
+/**
+ * Block Recently accessed items observer tests.
+ *
+ * @package    block_recentlyaccesseditems
+ * @copyright  2018 Victor Deniz <victor@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ * @since      Moodle 3.6
+ */
+class block_recentlyaccesseditems_observer_testcase extends advanced_testcase {
+    use mod_assign_test_generator;
+
+    /**
+     * Set up for every test
+     */
+    public function setUp() {
+        global $DB;
+
+        $this->resetAfterTest();
+        $this->setAdminUser();
+
+        // Block table name.
+        $this->table = "block_recentlyaccesseditems";
+
+        // Setup test data.
+        $this->course = $this->getDataGenerator()->create_course();
+
+        // Create users.
+        $this->student = self::getDataGenerator()->create_user();
+        $this->teacher = self::getDataGenerator()->create_user();
+
+        // Users enrolments.
+        $this->studentrole = $DB->get_record('role', array('shortname' => 'student'));
+        $this->teacherrole = $DB->get_record('role', array('shortname' => 'editingteacher'));
+        $this->getDataGenerator()->enrol_user($this->student->id, $this->course->id, $this->studentrole->id, 'manual');
+        $this->getDataGenerator()->enrol_user($this->teacher->id, $this->course->id, $this->teacherrole->id, 'manual');
+
+        // Create items.
+        $this->forum = $this->getDataGenerator()->create_module('forum', array('course' => $this->course));
+        $this->glossary = $this->getDataGenerator()->create_module('glossary', array('course' => $this->course));
+        $this->chat = $this->getDataGenerator()->create_module('chat', array('course' => $this->course));
+    }
+
+    /**
+     * Test items views are recorded
+     *
+     * When items events are triggered they are stored in the block_recentlyaccesseditems table.
+     */
+    public function test_item_view_recorded_testcase() {
+        global $DB;
+
+        // Empty table at the beggining.
+        $records = $DB->count_records($this->table, array());
+        $this->assertEquals(0, $records);
+
+        // Teacher access forum activity.
+        $this->setUser($this->teacher);
+        $event = \mod_forum\event\course_module_viewed::create(array('context' => context_module::instance($this->forum->cmid),
+                'objectid' => $this->forum->id));
+        $event->trigger();
+
+        // Student access chat activity.
+        $this->setUser($this->student);
+        $event1 = \mod_chat\event\course_module_viewed::create(array('context' => context_module::instance($this->chat->cmid),
+                'objectid' => $this->chat->id));
+        $event1->trigger();
+
+        $records = $DB->count_records($this->table, array('userid' => $this->teacher->id, 'courseid' => $this->course->id,
+                'cmid' => $this->forum->cmid));
+        $this->assertEquals(1, $records);
+
+        $records = $DB->count_records($this->table, array('userid' => $this->student->id, 'courseid' => $this->course->id, 'cmid' =>
+                $this->chat->cmid));
+        $this->assertEquals(1, $records);
+
+        $this->waitForSecond();
+        // Student access chat activity again after 1 second (no new record created, timeaccess updated).
+        $event2 = \mod_chat\event\course_module_viewed::create(array('context' => context_module::instance($this->chat->cmid),
+                'objectid' => $this->chat->id));
+        $event2->trigger();
+
+        $records = $DB->get_records($this->table, array('userid' => $this->student->id, 'courseid' => $this->course->id, 'cmid' =>
+                $this->chat->cmid));
+        $this->assertCount(1, $records);
+        $this->assertEquals($event2->timecreated, array_shift($records)->timeaccess);
+
+    }
+
+    /**
+     * Test removed items records are deleted.
+     *
+     * When a course module is removed, the records associated in the block_recentlyaccesseditems table are deleted.
+     */
+    public function test_item_delete_record_testcase() {
+        global $DB;
+
+        // Empty table at the beggining.
+        $records = $DB->count_records($this->table, array());
+        $this->assertEquals(0, $records);
+
+        // Teacher access forum activity.
+        $this->setUser($this->teacher);
+        $event = \mod_forum\event\course_module_viewed::create(array('context' => context_module::instance($this->forum->cmid),
+                'objectid' => $this->forum->id));
+        $event->trigger();
+
+        // Teacher access chat activity.
+        $event = \mod_chat\event\course_module_viewed::create(array('context' => context_module::instance($this->chat->cmid),
+                'objectid' => $this->chat->id));
+        $event->trigger();
+
+        // Student access chat activity.
+        $this->setUser($this->student);
+        $event = \mod_chat\event\course_module_viewed::create(array('context' => context_module::instance($this->chat->cmid),
+                'objectid' => $this->chat->id));
+        $event->trigger();
+
+        // Student access forum activity.
+        $event = \mod_forum\event\course_module_viewed::create(array('context' => context_module::instance($this->forum->cmid),
+                'objectid' => $this->forum->id));
+        $event->trigger();
+
+        $records = $DB->count_records($this->table, array('cmid' => $this->forum->cmid));
+        $this->assertEquals(2, $records);
+        course_delete_module($this->forum->cmid);
+        $records = $DB->count_records($this->table, array('cmid' => $this->forum->cmid));
+        $this->assertEquals(0, $records);
+        $records = $DB->count_records($this->table, array('cmid' => $this->chat->cmid));
+        $this->assertEquals(2, $records);
+    }
+}
\ No newline at end of file
diff --git a/blocks/recentlyaccesseditems/version.php b/blocks/recentlyaccesseditems/version.php
new file mode 100644 (file)
index 0000000..198c338
--- /dev/null
@@ -0,0 +1,27 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+/**
+ * Version details for the Recent activities block.
+ *
+ * @package    block_recentlyaccesseditems
+ * @copyright  2018 Victor Deniz <victor@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+defined('MOODLE_INTERNAL') || die();
+
+$plugin->version   = 2018101705;            // The current plugin version (Date: YYYYMMDDXX).
+$plugin->requires  = 2018101900;            // Requires this Moodle version.
+$plugin->component = 'block_recentlyaccesseditems'; // Full name of the plugin (used for diagnostics).
diff --git a/blocks/starredcourses/amd/build/main.min.js b/blocks/starredcourses/amd/build/main.min.js
new file mode 100644 (file)
index 0000000..8ebbc68
Binary files /dev/null and b/blocks/starredcourses/amd/build/main.min.js differ
diff --git a/blocks/starredcourses/amd/build/repository.min.js b/blocks/starredcourses/amd/build/repository.min.js
new file mode 100644 (file)
index 0000000..7c88600
Binary files /dev/null and b/blocks/starredcourses/amd/build/repository.min.js differ
diff --git a/blocks/starredcourses/amd/src/main.js b/blocks/starredcourses/amd/src/main.js
new file mode 100644 (file)
index 0000000..81d4130
--- /dev/null
@@ -0,0 +1,119 @@
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Javascript to initialise the starred courses block.
+ *
+ * @copyright   2018 Simey Lameze <simey@moodle.com>
+ * @license     http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+define(
+[
+    'jquery',
+    'core/notification',
+    'block_starredcourses/repository',
+    'core/pubsub',
+    'core/templates',
+    'core_course/events'
+],
+function(
+    $,
+    Notification,
+    Repository,
+    PubSub,
+    Templates,
+    CourseEvents
+) {
+
+    var SELECTORS = {
+        STARRED_COURSES_REGION_VIEW: '[data-region="starred-courses-view"]',
+        STARRED_COURSES_REGION: '[data-region="starred-courses-view-content"]'
+    };
+
+    /**
+     * Render the starred courses.
+     *
+     * @method renderCourses
+     * @param {object} root The root element for the starred view.
+     * @param {array} courses containing array of returned courses.
+     * @returns {promise} Resolved with HTML and JS strings
+     */
+    var renderCourses = function(root, courses) {
+        if (courses.length > 0) {
+            return Templates.render('block_starredcourses/view-cards', {
+                courses: courses
+            });
+        } else {
+            var nocoursesimg = root.find(SELECTORS.STARRED_COURSES_REGION_VIEW).attr('data-nocoursesimg');
+            return Templates.render('block_starredcourses/no-courses', {
+                nocoursesimg: nocoursesimg
+            });
+        }
+    };
+
+    /**
+     * Fetch user's starred courses and reload the content of the block.
+     *
+     * @param {object} root The root element for the starred view.
+     * @returns {promise} The updated content for the block.
+     */
+    var reloadContent = function(root) {
+        var content = root.find(SELECTORS.STARRED_COURSES_REGION);
+
+        var args = {
+            limit: 0,
+            offset: 0,
+        };
+
+        return Repository.getStarredCourses(args)
+            .then(function(courses) {
+                return renderCourses(root, courses);
+            }).then(function(html, js) {
+                return Templates.replaceNodeContents(content, html, js);
+            }).catch(Notification.exception);
+    };
+
+    /**
+     * Register event listeners for the block.
+     *
+     * @param {object} root The calendar root element
+     */
+    var registerEventListeners = function(root) {
+        PubSub.subscribe(CourseEvents.favourited, function() {
+            reloadContent(root);
+        });
+
+        PubSub.subscribe(CourseEvents.unfavorited, function() {
+            reloadContent(root);
+        });
+    };
+
+    /**
+     * Initialise all of the modules for the starred courses block.
+     *
+     * @param {object} root The root element for the block.
+     */
+    var init = function(root) {
+        root = $(root);
+
+        registerEventListeners(root);
+        reloadContent(root);
+    };
+
+    return {
+        init: init
+    };
+});
diff --git a/blocks/starredcourses/amd/src/repository.js b/blocks/starredcourses/amd/src/repository.js
new file mode 100644 (file)
index 0000000..7de121e
--- /dev/null
@@ -0,0 +1,53 @@
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * A javascript module to retrieve user's starred courses.
+ *
+ * @package    block_starredcourses
+ * @copyright  2018 Simey Lameze <simey@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+define(['jquery', 'core/ajax', 'core/notification'], function($, Ajax, Notification) {
+
+    /**
+     * Retrieve a list of starred courses.
+     *
+     * Valid args are:
+     * int limit    number of records to retrieve
+     * int offset   the offset of records to retrieve
+     *
+     * @method getStarredCourses
+     * @param {object} args The request arguments
+     * @return {promise} Resolved with an array of courses
+     */
+    var getStarredCourses = function(args) {
+
+        var request = {
+            methodname: 'block_starredcourses_get_starred_courses',
+            args: args
+        };
+
+        var promise = Ajax.call([request])[0];
+
+        promise.fail(Notification.exception);
+
+        return promise;
+    };
+
+    return {
+        getStarredCourses: getStarredCourses
+    };
+});
\ No newline at end of file
diff --git a/blocks/starredcourses/block_starredcourses.php b/blocks/starredcourses/block_starredcourses.php
new file mode 100644 (file)
index 0000000..2da98ce
--- /dev/null
@@ -0,0 +1,75 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Starred courses block.
+ *
+ * @package   block_starredcourses
+ * @copyright 2018 Simey Lameze <simey@moodle.com>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Starred courses block definition class.
+ *
+ * @package   block_starredcourses
+ * @copyright 2018 Simey Lameze <simey@moodle.com>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class block_starredcourses extends block_base {
+
+    /**
+     * Initialises the block.
+     *
+     * @return void
+     */
+    public function init() {
+        $this->title = get_string('pluginname', 'block_starredcourses');
+    }
+
+    /**
+     * Gets the block contents.
+     *
+     * @return string The block HTML.
+     */
+    public function get_content() {
+
+        if ($this->content !== null) {
+            return $this->content;
+        }
+
+        $renderable = new \block_starredcourses\output\main();
+        $renderer = $this->page->get_renderer('block_starredcourses');
+
+        $this->content = (object) [
+            'text' => $renderer->render($renderable),
+            'footer' => ''
+        ];
+
+        return $this->content;
+    }
+
+    /**
+     * Locations where block can be displayed.
+     *
+     * @return array
+     */
+    public function applicable_formats() {
+        return array('my' => true);
+    }
+}
diff --git a/blocks/starredcourses/classes/external.php b/blocks/starredcourses/classes/external.php
new file mode 100644 (file)
index 0000000..f5cb1c2
--- /dev/null
@@ -0,0 +1,111 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+/**
+ * Starred courses block external API
+ *
+ * @package    block_starredcourses
+ * @category   external
+ * @copyright  2018 Simey Lameze <simey@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+defined('MOODLE_INTERNAL') || die;
+
+require_once($CFG->libdir . '/externallib.php');
+
+require_once($CFG->dirroot . '/course/lib.php');
+require_once($CFG->dirroot . '/course/externallib.php');
+
+use \core_course\external\course_summary_exporter;
+
+/**
+ * Starred courses block external functions.
+ *
+ * @copyright  2018 Simey Lameze <simey@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class block_starredcourses_external extends core_course_external {
+
+    /**
+     * Returns description of method parameters
+     *
+     * @return external_function_parameters
+     * @since Moodle 3.6
+     */
+    public static function get_starred_courses_parameters() {
+        return new external_function_parameters([
+            'limit' => new external_value(PARAM_INT, 'Limit', VALUE_DEFAULT, 0),
+            'offset' => new external_value(PARAM_INT, 'Offset', VALUE_DEFAULT, 0)
+        ]);
+    }
+
+    /**
+     * Get users starred courses.
+     *
+     * @param int $limit Limit
+     * @param int $offset Offset
+     *
+     * @return  array list of courses and warnings
+     */
+    public static function get_starred_courses($limit, $offset) {
+        global $USER, $PAGE;
+
+        $params = self::validate_parameters(self::get_starred_courses_parameters(), [
+            'limit' => $limit,
+            'offset' => $offset
+        ]);
+
+        $limit = $params['limit'];
+        $offset = $params['offset'];
+
+        $usercontext = context_user::instance($USER->id);
+
+        self::validate_context($usercontext);
+        $PAGE->set_context($usercontext);
+        $renderer = $PAGE->get_renderer('core');
+
+        // Get the user favourites service, scoped to a single user (their favourites only).
+        $userservice = \core_favourites\service_factory::get_service_for_user_context($usercontext);
+
+        // Get the favourites, by type, for the user.
+        $favourites = $userservice->find_favourites_by_type('core_course', 'courses', $offset, $limit);
+
+        // Sort the favourites getting last added first.
+        usort($favourites, function($a, $b) {
+            if ($a->timemodified == $b->timemodified) return 0;
+            return ($a->timemodified > $b->timemodified) ? -1 : 1;
+        });
+
+        $formattedcourses = array_map(function($favourite) use ($renderer) {
+            $course = get_course($favourite->itemid);
+            $context = \context_course::instance($favourite->itemid);
+
+            $exporter = new course_summary_exporter($course, ['context' => $context, 'isfavourite' => true]);
+            return $exporter->export($renderer);
+        }, $favourites);
+
+        return $formattedcourses;
+    }
+
+    /**
+     * Returns description of method result value
+     *
+     * @return external_description
+     * @since Moodle 3.6
+     */
+    public static function get_starred_courses_returns() {
+        return new external_multiple_structure(course_summary_exporter::get_read_structure());
+    }
+}
diff --git a/blocks/starredcourses/classes/output/main.php b/blocks/starredcourses/classes/output/main.php
new file mode 100644 (file)
index 0000000..6b7d427
--- /dev/null
@@ -0,0 +1,59 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Class containing data for starred courses block.
+ *
+ * @package     block_starredcourses
+ * @copyright   2018 Simey Lameze <simey@moodle.com>
+ * @license     http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+namespace block_starredcourses\output;
+defined('MOODLE_INTERNAL') || die();
+
+use renderable;
+use renderer_base;
+use templatable;
+use core_course\external\course_summary_exporter;
+
+require_once($CFG->dirroot . '/course/lib.php');
+require_once($CFG->libdir . '/completionlib.php');
+
+/**
+ * Class containing data for starred courses block.
+ *
+ * @copyright 2018 Simey Lameze <simey@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class main implements renderable, templatable {
+
+    /**
+     * Export this data so it can be used as the context for a mustache template.
+     *
+     * @param \renderer_base $output
+     * @return array
+     */
+    public function export_for_template(renderer_base $output) {
+        global $USER;
+
+        $nocoursesurl = $output->image_url('courses', 'block_starredcourses')->out();
+
+        return [
+            'userid' => $USER->id,
+            'nocoursesimg' => $nocoursesurl
+        ];
+    }
+}
diff --git a/blocks/starredcourses/classes/output/renderer.php b/blocks/starredcourses/classes/output/renderer.php
new file mode 100644 (file)
index 0000000..9fc1a27
--- /dev/null
@@ -0,0 +1,48 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Starred courses block renderer.
+ *
+ * @package    block_starredcourses
+ * @copyright  2018 Simey Lameze <simey@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+namespace block_starredcourses\output;
+defined('MOODLE_INTERNAL') || die;
+
+use plugin_renderer_base;
+
+/**
+ * Starred courses block renderer.
+ *
+ * @package    block_starredcourses
+ * @copyright  2018 Simey Lameze <simey@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class renderer extends plugin_renderer_base {
+
+    /**
+     * Return the main content for the block.
+     *
+     * @param main $main The main renderable
+     * @return string HTML string
+     */
+    public function render_main(main $main) {
+        return $this->render_from_template('block_starredcourses/main',
+            $main->export_for_template($this));
+    }
+}
diff --git a/blocks/starredcourses/classes/privacy/provider.php b/blocks/starredcourses/classes/privacy/provider.php
new file mode 100644 (file)
index 0000000..c5e8c87
--- /dev/null
@@ -0,0 +1,45 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Privacy Subsystem implementation for block_starredcourses.
+ *
+ * @package    block_starredcourses
+ * @copyright  2018 Simey Lameze <simey@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+namespace block_starredcourses\privacy;
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Privacy Subsystem for block_starredcourses.
+ *
+ * @copyright  2018 Simey Lameze <simey@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class provider implements \core_privacy\local\metadata\null_provider {
+
+    /**
+     * Get the language string identifier with the component's language
+     * file to explain why this plugin stores no data.
+     *
+     * @return  string
+     */
+    public static function get_reason() : string {
+        return 'privacy:metadata';
+    }
+}
diff --git a/blocks/starredcourses/db/access.php b/blocks/starredcourses/db/access.php
new file mode 100644 (file)
index 0000000..2f5e365
--- /dev/null
@@ -0,0 +1,48 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Starred courses block capabilities.
+ *
+ * @package   block_starredcourses
+ * @copyright 2018 Simey Lameze <simey@moodle.com>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+$capabilities = array(
+
+    'block/starredcourses:myaddinstance' => array(
+        'captype' => 'write',
+        'contextlevel' => CONTEXT_SYSTEM,
+        'archetypes' => array(
+            'user' => CAP_ALLOW
+        ),
+
+        'clonepermissionsfrom' => 'moodle/my:manageblocks'
+    ),
+
+    'block/starredcourses:addinstance' => array(
+        'captype' => 'write',
+        'contextlevel' => CONTEXT_BLOCK,
+        'archetypes' => array(
+            'manager' => CAP_ALLOW
+        ),
+
+        'clonepermissionsfrom' => 'moodle/site:manageblocks'
+    ),
+);
diff --git a/blocks/starredcourses/db/services.php b/blocks/starredcourses/db/services.php
new file mode 100644 (file)
index 0000000..fc3ebb9
--- /dev/null
@@ -0,0 +1,38 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * File description.
+ *
+ * @package   block_starredcourses
+ * @copyright 2018 Simey Lameze <simey@moodle.com>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+$functions = array(
+
+    'block_starredcourses_get_starred_courses' => array(
+        'classpath' => 'block/starredcourses/classes/external.php',
+        'classname'   => 'block_starredcourses_external',
+        'methodname'  => 'get_starred_courses',
+        'description' => 'Get users starred courses.',
+        'type'        => 'read',
+        'ajax'        => true,
+    ),
+);
+
diff --git a/blocks/starredcourses/lang/en/block_starredcourses.php b/blocks/starredcourses/lang/en/block_starredcourses.php
new file mode 100644 (file)
index 0000000..8d7d7f1
--- /dev/null
@@ -0,0 +1,30 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Starred courses block language strings.
+ *
+ * @package   block_starredcourses
+ * @copyright 2018 Simey Lameze <simey@moodle.com>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+$string['nocourses'] = 'No starred courses';
+$string['pluginname'] = 'Starred courses';
+$string['privacy:metadata'] = 'The starred courses block does not store any personal data.';
+$string['starredcourses:addinstance'] = 'Add a new instance of starred courses block';
+$string['starredcourses:myaddinstance'] = 'Add a new instance of starred block to Dashboard';
+
diff --git a/blocks/starredcourses/pix/courses.svg b/blocks/starredcourses/pix/courses.svg
new file mode 100644 (file)
index 0000000..edc3e6d
--- /dev/null
@@ -0,0 +1,49 @@
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="157 -1305 148 125" preserveAspectRatio="xMinYMid meet">
+    <defs>
+        <style>
+            .cls-1 {
+            clip-path: url(#clip-Courses);
+            }
+            .cls-2 {
+            fill: #eee;
+            }
+            .cls-3 {
+            fill: #c4c8cc;
+            }
+            .cls-4 {
+            fill: #fff;
+            }
+        </style>
+        <clipPath id="clip-Courses">
+            <rect x="157" y="-1305" width="148" height="125"/>
+        </clipPath>
+    </defs>
+    <g id="Courses" class="cls-1">
+        <g id="Group_44" data-name="Group 44" transform="translate(-268 -1781)">
+            <ellipse id="Ellipse_41" data-name="Ellipse 41" class="cls-2" cx="74" cy="14.785" rx="74" ry="14.785" transform="translate(425 571.43)"/>
+            <rect id="Rectangle_87" data-name="Rectangle 87" class="cls-3" width="95.097" height="110.215" transform="translate(451.909 476)"/>
+            <g id="Group_43" data-name="Group 43" transform="translate(464.04 494)">
+                <rect id="Rectangle_88" data-name="Rectangle 88" class="cls-4" width="31.043" height="34" transform="translate(0)"/>
+                <rect id="Rectangle_89" data-name="Rectangle 89" class="cls-4" width="31.043" height="34" transform="translate(0 42)"/>
+                <rect id="Rectangle_90" data-name="Rectangle 90" class="cls-4" width="31.067" height="34" transform="translate(39.005)"/>
+                <rect id="Rectangle_91" data-name="Rectangle 91" class="cls-4" width="31.067" height="34" transform="translate(39.005 42)"/>
+                <rect id="Rectangle_92" data-name="Rectangle 92" class="cls-3" width="23.023" height="3.18" transform="translate(3.081 16.549)"/>
+                <rect id="Rectangle_93" data-name="Rectangle 93" class="cls-3" width="23.023" height="3.18" transform="translate(3.081 58.549)"/>
+                <rect id="Rectangle_94" data-name="Rectangle 94" class="cls-3" width="23.023" height="3.18" transform="translate(43.122 16.549)"/>
+                <rect id="Rectangle_95" data-name="Rectangle 95" class="cls-3" width="23.023" height="3.18" transform="translate(43.122 58.549)"/>
+                <rect id="Rectangle_96" data-name="Rectangle 96" class="cls-3" width="14.014" height="3.18" transform="translate(3.081 21.825)"/>
+                <rect id="Rectangle_97" data-name="Rectangle 97" class="cls-3" width="18.845" height="3.18" transform="translate(3.081 26.825)"/>
+                <rect id="Rectangle_98" data-name="Rectangle 98" class="cls-3" width="14.014" height="3.18" transform="translate(3.081 63.825)"/>
+                <rect id="Rectangle_99" data-name="Rectangle 99" class="cls-3" width="18.845" height="3.18" transform="translate(3.081 68.825)"/>
+                <rect id="Rectangle_100" data-name="Rectangle 100" class="cls-3" width="14.014" height="3.18" transform="translate(43.122 21.825)"/>
+                <rect id="Rectangle_101" data-name="Rectangle 101" class="cls-3" width="18.845" height="3.18" transform="translate(43.122 26.825)"/>
+                <rect id="Rectangle_102" data-name="Rectangle 102" class="cls-3" width="14.014" height="3.18" transform="translate(43.122 63.825)"/>
+                <rect id="Rectangle_103" data-name="Rectangle 103" class="cls-3" width="18.845" height="3.18" transform="translate(43.122 68.825)"/>
+                <ellipse id="Ellipse_42" data-name="Ellipse 42" class="cls-3" cx="5.658" cy="5.652" rx="5.658" ry="5.652" transform="translate(3.003 3.55)"/>
+                <ellipse id="Ellipse_43" data-name="Ellipse 43" class="cls-3" cx="5.658" cy="5.652" rx="5.658" ry="5.652" transform="translate(3.003 45.55)"/>
+                <ellipse id="Ellipse_44" data-name="Ellipse 44" class="cls-3" cx="5.658" cy="5.652" rx="5.658" ry="5.652" transform="translate(43.044 3.55)"/>
+                <ellipse id="Ellipse_45" data-name="Ellipse 45" class="cls-3" cx="5.658" cy="5.652" rx="5.658" ry="5.652" transform="translate(43.044 45.55)"/>
+            </g>
+        </g>
+    </g>
+</svg>
diff --git a/blocks/starredcourses/templates/main.mustache b/blocks/starredcourses/templates/main.mustache
new file mode 100644 (file)
index 0000000..b12dc66
--- /dev/null
@@ -0,0 +1,35 @@
+{{!
+    This file is part of Moodle - http://moodle.org/
+
+    Moodle is free software: you can redistribute it and/or modify
+    it under the terms of the GNU General Public License as published by
+    the Free Software Foundation, either version 3 of the License, or
+    (at your option) any later version.
+
+    Moodle is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+    GNU General Public License for more details.
+
+    You should have received a copy of the GNU General Public License
+    along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+}}
+{{!
+    @template block_starredcourses/main
+
+    This template renders the main content area for the starred courses block.
+
+    Example context (json):
+    {}
+}}
+
+<div id="block-starredcourses-{{uniqid}}" class="block-starredcourses" data-region="starred-courses">
+    <div class="container-fluid p-0">
+        {{> block_starredcourses/view }}
+    </div>
+</div>
+{{#js}}
+require(['block_starredcourses/main'], function(Main) {
+    Main.init('#block-starredcourses-{{uniqid}}');
+});
+{{/js}}
diff --git a/blocks/starredcourses/templates/no-courses.mustache b/blocks/starredcourses/templates/no-courses.mustache
new file mode 100644 (file)
index 0000000..1b74964
--- /dev/null
@@ -0,0 +1,28 @@
+{{!
+    This file is part of Moodle - http://moodle.org/
+     Moodle is free software: you can redistribute it and/or modify
+    it under the terms of the GNU General Public License as published by
+    the Free Software Foundation, either version 3 of the License, or
+    (at your option) any later version.
+     Moodle is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+    GNU General Public License for more details.
+     You should have received a copy of the GNU General Public License
+    along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+}}
+{{!
+    @template block_starredcourses/no-courses
+     This template renders the no courses message.
+     Example context (json):
+    {
+        "nocoursesimg": "https://moodlesite/theme/image.php/boost/block_recentcourses/1535727318/courses"
+    }
+}}
+<div class="text-xs-center text-center m-t-3" data-region="empty-message">
+    <img class="empty-placeholder-image-lg"
+         src="{{nocoursesimg}}"
+         alt="{{#str}} nocourses, block_starredcourses {{/str}}"
+         role="presentation">
+    <p class="text-muted mt-3">{{#str}} nocourses, block_starredcourses {{/str}}</p>
+</div>
diff --git a/blocks/starredcourses/templates/placeholder-course.mustache b/blocks/starredcourses/templates/placeholder-course.mustache
new file mode 100644 (file)
index 0000000..0e35afe
--- /dev/null
@@ -0,0 +1,26 @@
+{{!
+    This file is part of Moodle - http://moodle.org/
+     Moodle is free software: you can redistribute it and/or modify
+    it under the terms of the GNU General Public License as published by
+    the Free Software Foundation, either version 3 of the License, or
+    (at your option) any later version.
+     Moodle is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+    GNU General Public License for more details.
+     You should have received a copy of the GNU General Public License
+    along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+}}
+{{!
+    @template block_starredcourses/placeholder-course
+     This template renders an course card item loading placeholder for the starred courses block.
+     Example context (json):
+    {}
+}}
+<div class="card dashboard-card border-0">
+    <div class="card-img-top bg-pulse-grey w-100" style="height: 7rem">
+    </div>
+    <div class="card-body recent-course-info-container">
+        <div class="bg-pulse-grey w-100 m-b-3" style="height: 1rem"></div>
+    </div>
+</div>
diff --git a/blocks/starredcourses/templates/view-cards.mustache b/blocks/starredcourses/templates/view-cards.mustache
new file mode 100644 (file)
index 0000000..05d672a
--- /dev/null
@@ -0,0 +1,50 @@
+{{!
+    This file is part of Moodle - http://moodle.org/
+     Moodle is free software: you can redistribute it and/or modify
+    it under the terms of the GNU General Public License as published by
+    the Free Software Foundation, either version 3 of the License, or
+    (at your option) any later version.
+     Moodle is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+    GNU General Public License for more details.
+     You should have received a copy of the GNU General Public Licensebllsdsadfasfd
+    along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+}}
+{{!
+    @template block_starredcourses/view-cards
+     This template renders the carousel for the starredcourses block.
+     Example context (json):
+    {
+        "courses": [
+            {
+                "name": "Assignment due 1",
+                "viewurl": "https://moodlesite/course/view.php?id=2",
+                "courseimage": "https://moodlesite/pluginfile/123/course/overviewfiles/123.jpg",
+                "fullname": "course 3"
+            }
+        ]
+    }
+}}
+<div class="card-deck dashboard-card-deck" role="list">
+{{#courses}}
+    <div class="card dashboard-card m-b-1" role="listitem">
+        <a href="{{viewurl}}" tabindex="-1">
+            <div class="card-img dashboard-card-img" style='background-image: url("{{{courseimage}}}");'>
+                <span class="sr-only">{{#str}}aria:courseimage, core_course{{/str}}</span>
+                {{>core_course/favouriteicon }}
+            </div>
+        </a>
+        <div class="card-body pr-1 course-info-container" id="course-info-container-{{id}}">
+            <div class="d-flex align-items-start">
+                <div class="card-title mr-2">
+                    <a href="{{viewurl}}">
+                        <span class="sr-only">{{#str}}aria:coursename, core_course{{/str}}</span>
+                        {{#shortentext}}40, {{{fullname}}} {{/shortentext}}
+                    </a>
+                </div>
+            </div>
+        </div>
+    </div>
+{{/courses}}
+</div>
diff --git a/blocks/starredcourses/templates/view.mustache b/blocks/starredcourses/templates/view.mustache
new file mode 100644 (file)
index 0000000..8492cfb
--- /dev/null
@@ -0,0 +1,39 @@
+{{!
+    This file is part of Moodle - http://moodle.org/
+
+    Moodle is free software: you can redistribute it and/or modify
+    it under the terms of the GNU General Public License as published by
+    the Free Software Foundation, either version 3 of the License, or
+    (at your option) any later version.
+
+    Moodle is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+    GNU General Public License for more details.
+
+    You should have received a copy of the GNU General Public License
+    along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+}}
+{{!
+    @template block_starredcourses/view
+
+    This template renders the view for the starred courses block.
+
+    Example context (json):
+    {
+        "nocoursesimg": "https://moodlesite/theme/image.php/boost/block_starredcourses/1535727318/courses"
+    }
+}}
+<div id="starred-courses-view-{{uniqid}}"
+     data-region="starred-courses-view"
+     data-nocoursesimg="{{nocoursesimg}}">
+    <div data-region="starred-courses-view-content">
+        <div data-region="starred-courses-loading-placeholder">
+            <div class="card-deck dashboard-card-deck">
+                {{> block_starredcourses/placeholder-course }}
+                {{> block_starredcourses/placeholder-course }}
+                {{> block_starredcourses/placeholder-course }}
+            </div>
+        </div>
+    </div>
+</div>
diff --git a/blocks/starredcourses/version.php b/blocks/starredcourses/version.php
new file mode 100644 (file)
index 0000000..6514e19
--- /dev/null
@@ -0,0 +1,28 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Starred courses block version details.
+ *
+ * @package   block_starredcourses
+ * @copyright 2018 Simey Lameze <simey@moodle.com>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+defined('MOODLE_INTERNAL') || die;
+
+$plugin->version = 2018051402;
+$plugin->requires  = 2018050800;
+$plugin->component = 'block_starredcourses';
\ No newline at end of file
index 8c6327e..20df9d9 100644 (file)
@@ -93,6 +93,37 @@ class core_block_privacy_testcase extends provider_testcase {
         $this->assertTrue(in_array($blockmentees->context->id, $contextids));
     }
 
+    /**
+     * Test that user IDs are returned for a given context.
+     */
+    public function test_get_users_in_context() {
+        global $DB;
+        $u1 = $this->getDataGenerator()->create_user();
+        $u2 = $this->getDataGenerator()->create_user();
+        $u3 = $this->getDataGenerator()->create_user();
+
+        $course = $this->getDataGenerator()->create_course();
+
+        $manager = $this->get_block_manager(['region-a'], context_course::instance($course->id));
+        $manager->add_block('myprofile', 'region-a', 0, false);
+        $manager->load_blocks();
+        $blockmyprofile = $manager->get_blocks_for_region('region-a')[0];
+
+        $this->set_hidden_pref($blockmyprofile, true, $u1->id);
+        $this->set_hidden_pref($blockmyprofile, true, $u3->id);
+        $this->set_docked_pref($blockmyprofile, true, $u2->id);
+        $this->set_docked_pref($blockmyprofile, true, $u3->id);
+
+        $records = $DB->get_records('block_instances', ['blockname' => 'myprofile']);
+        $record = array_shift($records);
+        $blockcontext = context_block::instance($record->id);
+
+        $userlist = new \core_privacy\local\request\userlist($blockcontext, 'core_block');
+        provider::get_users_in_context($userlist);
+        $this->assertCount(3, $userlist->get_userids());
+    }
+
+
     public function test_delete_data_for_user() {
         global $DB;
         $dg = $this->getDataGenerator();
@@ -266,6 +297,47 @@ class core_block_privacy_testcase extends provider_testcase {
             'name' => "docked_block_instance_{$blockmentees->instance->id}"]));
     }
 
+    /**
+     * Test the deletion of data related to a context and a list of users.
+     */
+    public function test_delete_data_for_users() {
+        global $DB;
+        $u1 = $this->getDataGenerator()->create_user();
+        $u2 = $this->getDataGenerator()->create_user();
+        $u3 = $this->getDataGenerator()->create_user();
+
+        $course = $this->getDataGenerator()->create_course();
+
+        $manager = $this->get_block_manager(['region-a'], context_course::instance($course->id));
+        $manager->add_block('myprofile', 'region-a', 0, false);
+        $manager->load_blocks();
+        $blockmyprofile = $manager->get_blocks_for_region('region-a')[0];
+
+        $this->set_hidden_pref($blockmyprofile, true, $u1->id);
+        $this->set_hidden_pref($blockmyprofile, true, $u3->id);
+        $this->set_docked_pref($blockmyprofile, true, $u2->id);
+        $this->set_docked_pref($blockmyprofile, true, $u3->id);
+
+        $records = $DB->get_records('block_instances', ['blockname' => 'myprofile']);
+        $record = array_shift($records);
+        $blockcontext = context_block::instance($record->id);
+
+        $userlist = new \core_privacy\local\request\userlist($blockcontext, 'core_block');
+        provider::get_users_in_context($userlist);
+        $this->assertCount(3, $userlist->get_userids());
+
+        // Delete preferences for user 1 and 3 for the my profile block.
+        $userlist = new \core_privacy\local\request\approved_userlist($blockcontext, 'core_block', [$u1->id, $u3->id]);
+        provider::delete_data_for_users($userlist);
+
+        // Only user 2's preference is left.
+        $this->assertCount(1, $DB->get_records('user_preferences',
+                ['name' => "docked_block_instance_{$blockcontext->instanceid}"]));
+        // All of these are gone.
+        $this->assertEmpty($DB->get_records('user_preferences',
+                ['name' => "block{$blockcontext->instanceid}hidden"]));
+    }
+
     public function test_export_data_for_user() {
         global $DB;
         $dg = $this->getDataGenerator();
index e3acab9..560466b 100644 (file)
@@ -49,7 +49,8 @@ require_once($CFG->dirroot . '/blog/locallib.php');
  */
 class provider implements
     \core_privacy\local\metadata\provider,
-    \core_privacy\local\request\subsystem\provider {
+    \core_privacy\local\request\subsystem\provider,
+    \core_privacy\local\request\core_userlist_provider {
 
     /**
      * Returns metadata.
@@ -164,6 +165,53 @@ class provider implements
         return $contextlist;
     }
 
+    /**
+     * Get the list of contexts that contain user information for the specified user.
+     *
+     * @param \core_privacy\local\request\userlist $userlist The userlist containing the list of users who have
+     * data in this context/plugin combination.
+     */
+    public static function get_users_in_context(\core_privacy\local\request\userlist $userlist) {
+        global $DB;
+        $context = $userlist->get_context();
+        if ($context->contextlevel == CONTEXT_COURSE || $context->contextlevel == CONTEXT_MODULE) {
+
+            $params = ['contextid' => $context->id];
+
+            $sql = "SELECT p.id, p.userid
+                      FROM {post} p
+                      JOIN {blog_association} ba ON ba.blogid = p.id AND ba.contextid = :contextid";
+
+            $posts = $DB->get_records_sql($sql, $params);
+            $userids = array_map(function($post) {
+                return $post->userid;
+            }, $posts);
+            $userlist->add_users($userids);
+
+            if (!empty($posts)) {
+                // Add any user's who posted on the blog.
+                list($insql, $inparams) = $DB->get_in_or_equal(array_keys($posts), SQL_PARAMS_NAMED);
+                \core_comment\privacy\provider::get_users_in_context_from_sql($userlist, 'c', 'blog', 'format_blog', null, $insql,
+                    $inparams);
+            }
+        } else if ($context->contextlevel == CONTEXT_USER) {
+            $params = ['userid' => $context->instanceid];
+
+            $sql = "SELECT userid
+                      FROM {blog_external}
+                     WHERE userid = :userid";
+            $userlist->add_from_sql('userid', $sql, $params);
+
+            $sql = "SELECT userid
+                      FROM {post}
+                     WHERE userid = :userid";
+            $userlist->add_from_sql('userid', $sql, $params);
+
+            // Add any user's who posted on the blog.
+            \core_comment\privacy\provider::get_users_in_context_from_sql($userlist, 'c', 'blog', 'format_blog', $context->id);
+        }
+    }
+
     /**
      * Export all user data for the specified user, in the specified contexts.
      *
@@ -417,6 +465,42 @@ class provider implements
         }
     }
 
+    /**
+     * Delete multiple users within a single context.
+     *
+     * @param   approved_userlist       $userlist The approved context and user information to delete information for.
+     */
+    public static function delete_data_for_users(\core_privacy\local\request\approved_userlist $userlist) {
+        global $DB;
+
+        $context = $userlist->get_context();
+        $userids = $userlist->get_userids();
+
+        if ($context->contextlevel == CONTEXT_USER) {
+            // If one of the listed users matches this context then delete the blog, associations, and comments.
+            if (array_search($context->instanceid, $userids) !== false) {
+                self::delete_all_user_data($context);
+                \core_comment\privacy\provider::delete_comments_for_all_users($context, 'blog', 'format_blog');
+                return;
+            }
+            \core_comment\privacy\provider::delete_comments_for_users($userlist, 'blog', 'format_blog');
+        } else {
+            list($insql, $inparams) = $DB->get_in_or_equal($userids, SQL_PARAMS_NAMED);
+            $sql = "SELECT ba.id
+                      FROM {blog_association} ba
+                      JOIN {post} p ON p.id = ba.blogid
+                     WHERE ba.contextid = :contextid
+                       AND p.userid $insql";
+            $inparams['contextid'] = $context->id;
+            $associds = $DB->get_fieldset_sql($sql, $inparams);
+
+            if (!empty($associds)) {
+                list($insql, $inparams) = $DB->get_in_or_equal($associds, SQL_PARAMS_NAMED, 'param', true);
+                $DB->delete_records_select('blog_association', "id $insql", $inparams);
+            }
+        }
+    }
+
     /**
      * Helper method to delete all user data.
      *
index a71faa5..8db5bdd 100644 (file)
@@ -129,6 +129,79 @@ class core_blog_privacy_testcase extends provider_testcase {
         $this->assertTrue(in_array(context_course::instance($c1->id)->id, $contextids));
     }
 
+    /**
+     * Test that user IDs are returned for a specificed course or module context.
+     */
+    public function test_get_users_in_context_course_and_module() {
+        $user1 = $this->getDataGenerator()->create_user();
+        $user2 = $this->getDataGenerator()->create_user();
+        $course = $this->getDataGenerator()->create_course();
+        $c1ctx = context_course::instance($course->id);
+
+        $post = $this->create_post(['userid' => $user1->id, 'courseid' => $course->id]);
+        $entry = new blog_entry($post->id);
+        $entry->add_association($c1ctx->id);
+
+        // Add a comment from user 2.
+        $comment = $this->get_comment_object(context_user::instance($user1->id), $entry->id);
+        $this->setUser($user2);
+        $comment->add('Nice blog post');
+
+        $userlist = new \core_privacy\local\request\userlist($c1ctx, 'core_blog');
+        provider::get_users_in_context($userlist);
+        $userids = $userlist->get_userids();
+        $this->assertCount(2, $userids);
+
+        // Add an association for a module.
+        $cm1a = $this->getDataGenerator()->create_module('page', ['course' => $course]);
+        $cm1ctx = context_module::instance($cm1a->cmid);
+
+        $post2 = $this->create_post(['userid' => $user2->id, 'courseid' => $course->id]);
+        $entry2 = new blog_entry($post2->id);
+        $entry2->add_association($cm1ctx->id);
+
+        $userlist = new \core_privacy\local\request\userlist($cm1ctx, 'core_blog');
+        provider::get_users_in_context($userlist);
+        $userids = $userlist->get_userids();
+        $this->assertCount(1, $userids);
+    }
+
+    /**
+     * Test that user IDs are returned for a specificed user context.
+     */
+    public function test_get_users_in_context_user_context() {
+        $user1 = $this->getDataGenerator()->create_user();
+        $user2 = $this->getDataGenerator()->create_user();
+        $u1ctx = context_user::instance($user1->id);
+
+        $post = $this->create_post(['userid' => $user1->id]);
+        $entry = new blog_entry($post->id);
+
+        // Add a comment from user 2.
+        $comment = $this->get_comment_object($u1ctx, $entry->id);
+        $this->setUser($user2);
+        $comment->add('Another nice blog post');
+
+        $userlist = new \core_privacy\local\request\userlist($u1ctx, 'core_blog');
+        provider::get_users_in_context($userlist);
+        $userids = $userlist->get_userids();
+        $this->assertCount(2, $userids);
+    }
+
+    /**
+     * Test that user IDs are returned for a specificed user context for an external blog.
+     */
+    public function test_get_users_in_context_external_blog() {
+        $user1 = $this->getDataGenerator()->create_user();
+        $u1ctx = context_user::instance($user1->id);
+        $extu1 = $this->create_external_blog(['userid' => $user1->id]);
+
+        $userlist = new \core_privacy\local\request\userlist($u1ctx, 'core_blog');
+        provider::get_users_in_context($userlist);
+        $userids = $userlist->get_userids();
+        $this->assertCount(1, $userids);
+    }
+
     public function test_delete_data_for_user() {
         global $DB;
 
@@ -629,6 +702,135 @@ class core_blog_privacy_testcase extends provider_testcase {
 
     }
 
+    /**
+     * Test that deleting of blog information in a user context works as desired.
+     */
+    public function test_delete_data_for_users_user_context() {
+        global $DB;
+
+        $u1 = $this->getDataGenerator()->create_user();
+        $u2 = $this->getDataGenerator()->create_user();
+        $u3 = $this->getDataGenerator()->create_user();
+        $u4 = $this->getDataGenerator()->create_user();
+        $u5 = $this->getDataGenerator()->create_user();
+
+        $u1ctx = context_user::instance($u1->id);
+
+        $post = $this->create_post(['userid' => $u1->id]);
+        $entry = new blog_entry($post->id);
+
+        $comment = $this->get_comment_object($u1ctx, $entry->id);
+        $this->setUser($u1);
+        $comment->add('Hello, I created the blog');
+        $this->setUser($u2);
+        $comment->add('User 2 making a comment.');
+        $this->setUser($u3);
+        $comment->add('User 3 here.');
+        $this->setUser($u4);
+        $comment->add('User 4 is nice.');
+        $this->setUser($u5);
+        $comment->add('User 5 for the win.');
+
+        // This will only delete the comments made by user 4 and 5.
+        $this->assertCount(5, $DB->get_records('comments', ['contextid' => $u1ctx->id]));
+        $userlist = new \core_privacy\local\request\approved_userlist($u1ctx, 'core_blog', [$u4->id, $u5->id]);
+        provider::delete_data_for_users($userlist);
+        $this->assertCount(3, $DB->get_records('comments', ['contextid' => $u1ctx->id]));
+        $this->assertCount(1, $DB->get_records('post', ['userid' => $u1->id]));
+
+        // As the owner of the post is here everything will be deleted.
+        $userlist = new \core_privacy\local\request\approved_userlist($u1ctx, 'core_blog', [$u1->id, $u2->id]);
+        provider::delete_data_for_users($userlist);
+        $this->assertEmpty($DB->get_records('comments', ['contextid' => $u1ctx->id]));
+        $this->assertEmpty($DB->get_records('post', ['userid' => $u1->id]));
+    }
+
+    /**
+     * Test that deleting of an external blog in a user context works as desired.
+     */
+    public function test_delete_data_for_users_external_blog() {
+        global $DB;
+
+        $u1 = $this->getDataGenerator()->create_user();
+        $u2 = $this->getDataGenerator()->create_user();
+
+        $u1ctx = context_user::instance($u1->id);
+        $u2ctx = context_user::instance($u2->id);
+
+        $post = $this->create_external_blog(['userid' => $u1->id, 'url' => 'https://moodle.org', 'name' => 'Moodle RSS']);
+        $post2 = $this->create_external_blog(['userid' => $u2->id, 'url' => 'https://moodle.com', 'name' => 'Some other thing']);
+
+        // Check that we have two external blogs created.
+        $this->assertCount(2, $DB->get_records('blog_external'));
+        // This will only delete the external blog for user 1.
+        $userlist = new \core_privacy\local\request\approved_userlist($u1ctx, 'core_blog', [$u1->id, $u2->id]);
+        provider::delete_data_for_users($userlist);
+        $this->assertCount(1, $DB->get_records('blog_external'));
+    }
+
+    public function test_delete_data_for_users_course_and_module_context() {
+        global $DB;
+
+        $u1 = $this->getDataGenerator()->create_user();
+        $u2 = $this->getDataGenerator()->create_user();
+        $u3 = $this->getDataGenerator()->create_user();
+        $u4 = $this->getDataGenerator()->create_user();
+        $u5 = $this->getDataGenerator()->create_user();
+
+        $course = $this->getDataGenerator()->create_course();
+        $module = $this->getDataGenerator()->create_module('page', ['course' => $course]);
+
+        $u1ctx = context_user::instance($u1->id);
+        $u3ctx = context_user::instance($u3->id);
+        $c1ctx = context_course::instance($course->id);
+        $cm1ctx = context_module::instance($module->cmid);
+
+        // Blog with course association.
+        $post1 = $this->create_post(['userid' => $u1->id, 'courseid' => $course->id]);
+        $entry1 = new blog_entry($post1->id);
+        $entry1->add_association($c1ctx->id);
+
+        // Blog with module association.
+        $post2 = $this->create_post(['userid' => $u3->id, 'courseid' => $course->id]);
+        $entry2 = new blog_entry($post2->id);
+        $entry2->add_association($cm1ctx->id);
+
+        $comment = $this->get_comment_object($u1ctx, $entry1->id);
+        $this->setUser($u1);
+        $comment->add('Hello, I created the blog');
+        $this->setUser($u2);
+        $comment->add('comment on first course blog');
+        $this->setUser($u4);
+        $comment->add('user 4 on course blog');
+
+        $comment = $this->get_comment_object($u3ctx, $entry2->id);
+        $this->setUser($u3);
+        $comment->add('Hello, I created the module blog');
+        $this->setUser($u2);
+        $comment->add('I am commenting on both');
+        $this->setUser($u5);
+        $comment->add('User 5 for modules');
+
+        $this->assertCount(6, $DB->get_records('comments', ['component' => 'blog']));
+        $this->assertCount(2, $DB->get_records('post', ['courseid' => $course->id]));
+        $this->assertCount(2, $DB->get_records('blog_association'));
+
+        // When using the course or module context we are only removing the blog associations and the comments.
+        $userlist = new \core_privacy\local\request\approved_userlist($c1ctx, 'core_blog', [$u2->id, $u1->id, $u5->id]);
+        provider::delete_data_for_users($userlist);
+        // Only one of the blog_associations should be removed. Everything else should be as before.
+        $this->assertCount(6, $DB->get_records('comments', ['component' => 'blog']));
+        $this->assertCount(2, $DB->get_records('post', ['courseid' => $course->id]));
+        $this->assertCount(1, $DB->get_records('blog_association'));
+
+        $userlist = new \core_privacy\local\request\approved_userlist($cm1ctx, 'core_blog', [$u2->id, $u1->id, $u3->id]);
+        provider::delete_data_for_users($userlist);
+        // Now we've removed the other association.
+        $this->assertCount(6, $DB->get_records('comments', ['component' => 'blog']));
+        $this->assertCount(2, $DB->get_records('post', ['courseid' => $course->id]));
+        $this->assertEmpty($DB->get_records('blog_association'));
+    }
+
     /**
      * Create a blog post.
      *
index 3332421..8ed10c2 100644 (file)
@@ -20,7 +20,9 @@
  * @copyright  2018 Zig Tan <zig@moodle.com>
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
+
 namespace core_calendar\privacy;
+
 defined('MOODLE_INTERNAL') || die();
 
 use \core_privacy\local\metadata\collection;
@@ -29,6 +31,8 @@ use \core_privacy\local\request\context;
 use \core_privacy\local\request\contextlist;
 use \core_privacy\local\request\transform;
 use \core_privacy\local\request\writer;
+use \core_privacy\local\request\userlist;
+use \core_privacy\local\request\approved_userlist;
 
 /**
  * Privacy Subsystem for core_calendar implementing metadata, plugin, and user_preference providers.
@@ -38,9 +42,10 @@ use \core_privacy\local\request\writer;
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
 class provider implements
-    \core_privacy\local\metadata\provider,
-    \core_privacy\local\request\plugin\provider,
-    \core_privacy\local\request\user_preference_provider
+        \core_privacy\local\metadata\provider,
+        \core_privacy\local\request\plugin\provider,
+        \core_privacy\local\request\core_userlist_provider,
+        \core_privacy\local\request\user_preference_provider
 {
 
     /**
@@ -149,6 +154,101 @@ class provider implements
         return $contextlist;
     }
 
+    /**
+     * Get the list of users within a specific context.
+     *
+     * @param userlist $userlist The userlist containing the list of users who have data in this context/plugin combination.
+     */
+    public static function get_users_in_context(userlist $userlist) {
+        $context = $userlist->get_context();
+
+        $allowedcontexts = [
+            CONTEXT_SYSTEM,
+            CONTEXT_COURSECAT,
+            CONTEXT_COURSE,
+            CONTEXT_MODULE,
+            CONTEXT_USER
+        ];
+
+        if (!in_array($context->contextlevel, $allowedcontexts)) {
+            return;
+        }
+
+        $params = [
+            'modulecontext'      => CONTEXT_MODULE,
+            'contextid'          => $context->id,
+        ];
+
+        $sql = "SELECT e.userid
+                  FROM {course_modules} cm
+                  JOIN {modules} m
+                       ON m.id = cm.module
+                  JOIN {event} e
+                       ON e.modulename = m.name
+                          AND e.courseid = cm.course
+                          AND e.instance = cm.instance
+                  JOIN {context} ctx
+                       ON ctx.instanceid = cm.id
+                          AND ctx.contextlevel = :modulecontext
+                 WHERE ctx.id = :contextid";
+
+        $userlist->add_from_sql('userid', $sql, $params);
+
+        // Calendar Events can exist at Site, Course Category, Course, Course Group, User, or Course Modules contexts.
+        $params = [
+            'sitecontext'        => CONTEXT_SYSTEM,
+            'categorycontext'    => CONTEXT_COURSECAT,
+            'coursecontext'      => CONTEXT_COURSE,
+            'groupcontext'       => CONTEXT_COURSE,
+            'usercontext'        => CONTEXT_USER,
+            'contextid'          => $context->id
+        ];
+
+        // Get contexts of Calendar Events for the owner.
+        $sql = "SELECT e.userid
+                  FROM {event} e
+                  JOIN {context} ctx
+                       ON (ctx.contextlevel = :sitecontext
+                          AND e.eventtype = 'site')
+                       OR (ctx.contextlevel = :categorycontext
+                          AND ctx.instanceid = e.categoryid
+                          AND e.eventtype = 'category')
+                       OR (ctx.contextlevel = :coursecontext
+                          AND ctx.instanceid = e.courseid
+                          AND e.eventtype = 'course')
+                       OR (ctx.contextlevel = :groupcontext
+                          AND ctx.instanceid = e.courseid
+                          AND e.eventtype = 'group')
+                       OR (ctx.contextlevel = :usercontext
+                          AND ctx.instanceid = e.userid
+                          AND e.eventtype = 'user')
+                 WHERE ctx.id = :contextid";
+
+        $userlist->add_from_sql('userid', $sql, $params);
+
+        // Get contexts for Calendar Subscriptions for the owner.
+        $sql = "SELECT s.userid
+                  FROM {event_subscriptions} s
+                  JOIN {context} ctx
+                       ON (ctx.contextlevel = :sitecontext
+                          AND s.eventtype = 'site')
+                       OR (ctx.instanceid = s.categoryid
+                          AND ctx.contextlevel = :categorycontext
+                          AND s.eventtype = 'category')
+                       OR (ctx.instanceid = s.courseid
+                          AND ctx.contextlevel = :coursecontext
+                          AND s.eventtype = 'course')
+                       OR (ctx.instanceid = s.courseid
+                          AND ctx.contextlevel = :groupcontext
+                          AND s.eventtype = 'group')
+                       OR (ctx.instanceid = s.userid
+                          AND ctx.contextlevel = :usercontext
+                          AND s.eventtype = 'user')
+                 WHERE ctx.id = :contextid";
+
+        $userlist->add_from_sql('userid', $sql, $params);
+    }
+
     /**
      * Export all user data for the specified user, in the specified contexts.
      *
@@ -198,6 +298,42 @@ class provider implements
         }
     }
 
+    /**
+     * Delete multiple users within a single context.
+     *
+     * @param approved_userlist $userlist The approved context and user information to delete information for.
+     */
+    public static function delete_data_for_users(approved_userlist $userlist) {
+        $context = $userlist->get_context();
+        $userids = $userlist->get_userids();
+
+        $allowedcontexts = [
+            CONTEXT_SYSTEM,
+            CONTEXT_COURSECAT,
+            CONTEXT_COURSE,
+            CONTEXT_MODULE,
+            CONTEXT_USER
+        ];
+
+        if (!in_array($context->contextlevel, $allowedcontexts)) {
+            return;
+        }
+
+        if (empty($userids)) {
+            return;
+        }
+
+        // Delete all Calendar Events in the specified context in batches.
+        if ($eventids = array_keys(self::get_calendar_event_ids_by_context($context, $userids))) {
+            self::delete_batch_records('event', 'id', $eventids);
+        }
+
+        // Delete all Calendar Subscriptions in the specified context in batches.
+        if ($subscriptionids = array_keys(self::get_calendar_subscription_ids_by_context($context, $userids))) {
+            self::delete_batch_records('event_subscriptions', 'id', $subscriptionids);
+        }
+    }
+
     /**
      * Delete all user data for the specified user, in the specified contexts.
      *
@@ -334,18 +470,25 @@ class provider implements
     }
 
     /**
-     * Helper function to return all Calendar Event id results for a specified context.
+     * Helper function to return all Calendar Event id results for a specified context and optionally
+     * included user list.
      *
      * @param \context $context
+     * @param array $userids
      * @return array|null
      * @throws \dml_exception
      */
-    protected static function get_calendar_event_ids_by_context(\context $context) {
+    protected static function get_calendar_event_ids_by_context(\context $context, $userids = array()) {
         global $DB;
 
-        // Calendar Events can exist at Site, Course Category, Course, Course Group, User, or Course Modules contexts.
-        $events = null;
+        $whereusersql = '';
+        $userparams = array();
+        if (!empty($userids)) {
+            list($usersql, $userparams) = $DB->get_in_or_equal($userids, SQL_PARAMS_NAMED);
+            $whereusersql = "AND e.userid {$usersql}";
+        }
 
+        // Calendar Events can exist at Site, Course Category, Course, Course Group, User, or Course Modules contexts.
         if ($context->contextlevel == CONTEXT_MODULE) { // Course Module Contexts.
             $params = [
                 'modulecontext'     => $context->contextlevel,
@@ -356,11 +499,17 @@ class provider implements
             $sql = "SELECT DISTINCT
                            e.id AS eventid
                       FROM {context} ctx
-                INNER JOIN {course_modules} cm ON cm.id = ctx.instanceid AND ctx.contextlevel = :modulecontext
-                INNER JOIN {modules} m ON m.id = cm.module
-                INNER JOIN {event} e ON e.modulename = m.name AND e.courseid = cm.course AND e.instance = cm.instance
-                     WHERE ctx.id = :contextid";
-            $events = $DB->get_records_sql($sql, $params);
+                INNER JOIN {course_modules} cm
+                           ON cm.id = ctx.instanceid
+                              AND ctx.contextlevel = :modulecontext
+                INNER JOIN {modules} m
+                           ON m.id = cm.module
+                INNER JOIN {event} e
+                           ON e.modulename = m.name
+                              AND e.courseid = cm.course
+                              AND e.instance = cm.instance
+                     WHERE ctx.id = :contextid
+                           {$whereusersql}";
         } else {                                        // Other Moodle Contexts.
             $params = [
                 'sitecontext'       => CONTEXT_SYSTEM,
@@ -375,26 +524,38 @@ class provider implements
             $sql = "SELECT DISTINCT
                            e.id AS eventid
                       FROM {context} ctx
-                INNER JOIN {event} e ON
-                           (e.eventtype = 'site' AND ctx.contextlevel = :sitecontext) OR
-                           (e.categoryid = ctx.instanceid AND e.eventtype = 'category' AND ctx.contextlevel = :categorycontext) OR
-                           (e.courseid = ctx.instanceid AND (e.eventtype = 'course' OR e.eventtype = 'group' OR e.modulename != '0') AND ctx.contextlevel = :coursecontext) OR
-                           (e.userid = ctx.instanceid AND e.eventtype = 'user' AND ctx.contextlevel = :usercontext)
-                     WHERE ctx.id = :contextid";
-            $events = $DB->get_records_sql($sql, $params);
+                INNER JOIN {event} e
+                           ON (e.eventtype = 'site'
+                              AND ctx.contextlevel = :sitecontext)
+                           OR (e.categoryid = ctx.instanceid
+                              AND e.eventtype = 'category'
+                              AND ctx.contextlevel = :categorycontext)
+                           OR (e.courseid = ctx.instanceid
+                              AND (e.eventtype = 'course'
+                                  OR e.eventtype = 'group'
+                                  OR e.modulename != '0')
+                              AND ctx.contextlevel = :coursecontext)
+                           OR (e.userid = ctx.instanceid
+                              AND e.eventtype = 'user'
+                              AND ctx.contextlevel = :usercontext)
+                     WHERE ctx.id = :contextid
+                           {$whereusersql}";
         }
+        $params += $userparams;
 
-        return $events;
+        return $DB->get_records_sql($sql, $params);
     }
 
     /**
-     * Helper function to return all Calendar Subscription id results for a specified context.
+     * Helper function to return all Calendar Subscription id results for a specified context and optionally
+     * included user list.
      *
      * @param \context $context
+     * @param array $userids
      * @return array
      * @throws \dml_exception
      */
-    protected static function get_calendar_subscription_ids_by_context(\context $context) {
+    protected static function get_calendar_subscription_ids_by_context(\context $context, $userids = array()) {
         global $DB;
 
         // Calendar Subscriptions can exist at Site, Course Category, Course, Course Group, or User contexts.
@@ -407,17 +568,36 @@ class provider implements
             'contextid'         => $context->id
         ];
 
+        $whereusersql = '';
+        $userparams = array();
+        if (!empty($userids)) {
+            list($usersql, $userparams) = $DB->get_in_or_equal($userids, SQL_PARAMS_NAMED);
+            $whereusersql = "AND s.userid {$usersql}";
+        }
+
         // Get Calendar Subscriptions for the specified context.
         $sql = "SELECT DISTINCT
                        s.id AS subscriptionid
                   FROM {context} ctx
-            INNER JOIN {event_subscriptions} s ON
-                       (s.eventtype = 'site' AND ctx.contextlevel = :sitecontext) OR
-                       (s.categoryid = ctx.instanceid AND s.eventtype = 'category' AND ctx.contextlevel = :categorycontext) OR
-                       (s.courseid = ctx.instanceid AND s.eventtype = 'course' AND ctx.contextlevel = :coursecontext) OR
-                       (s.courseid = ctx.instanceid AND s.eventtype = 'group' AND ctx.contextlevel = :groupcontext) OR
-                       (s.userid = ctx.instanceid AND s.eventtype = 'user' AND ctx.contextlevel = :usercontext)
-                 WHERE ctx.id = :contextid";
+            INNER JOIN {event_subscriptions} s
+                       ON (s.eventtype = 'site'
+                          AND ctx.contextlevel = :sitecontext)
+                       OR (s.categoryid = ctx.instanceid
+                          AND s.eventtype = 'category'
+                          AND ctx.contextlevel = :categorycontext)
+                       OR (s.courseid = ctx.instanceid
+                          AND s.eventtype = 'course'
+                          AND ctx.contextlevel = :coursecontext)
+                       OR (s.courseid = ctx.instanceid
+                          AND s.eventtype = 'group'
+                          AND ctx.contextlevel = :groupcontext)
+                       OR (s.userid = ctx.instanceid
+                          AND s.eventtype = 'user'
+                          AND ctx.contextlevel = :usercontext)
+                 WHERE ctx.id = :contextid
+                       {$whereusersql}";
+
+        $params += $userparams;
 
         return $DB->get_records_sql($sql, $params);
     }
@@ -552,5 +732,4 @@ class provider implements
             $DB->delete_records_list($tablename, $field, $batchrecord);
         }
     }
-
 }
index 4a47348..0921296 100644 (file)
@@ -34,6 +34,7 @@ use \core_privacy\local\metadata\collection;
 use \core_privacy\local\request\approved_contextlist;
 use \core_privacy\local\request\writer;
 use \core_privacy\tests\provider_testcase;
+use \core_privacy\local\request\approved_userlist;
 
 /**
  * Unit tests for calendar/classes/privacy/provider
@@ -103,7 +104,7 @@ class core_calendar_privacy_testcase extends provider_testcase {
 
         // Add a Calendar Subscription and Group Calendar Event to Course 3.
         $this->create_test_calendar_subscription('course', 'https://calendar.google.com/', $user->id, 0, $course3->id);
-        $this->create_test_standard_calendar_event('group', $user->id, time(), '', 0, $course1->id, $course3group->id);
+        $this->create_test_standard_calendar_event('group', $user->id, time(), '', 0, $course3->id, $course3group->id);
 
         // The user will be in these contexts.
         $usercontextids = [
@@ -429,6 +430,300 @@ class core_calendar_privacy_testcase extends provider_testcase {
         $this->assertCount(1, $eventsubscriptions);
     }
 
+    /**
+     * Test that only users with a user context are fetched.
+     */
+    public function test_get_users_in_context() {
+        $component = 'core_calendar';
+
+        // Create user1 to create Calendar Events and Subscriptions.
+        $user1 = $this->getDataGenerator()->create_user();
+        $usercontext1 = context_user::instance($user1->id);
+        // Create user2 to create Calendar Events and Subscriptions.
+        $user2 = $this->getDataGenerator()->create_user();
+        $usercontext2 = context_user::instance($user2->id);
+        // Create user3 to create Calendar Events and Subscriptions.
+        $user3 = $this->getDataGenerator()->create_user();
+        $usercontext3 = context_user::instance($user3->id);
+
+        // Create a Category and Courses to assign Calendar Events and Subscriptions.
+        $category = $this->getDataGenerator()->create_category();
+        $categorycontext = context_coursecat::instance($category->id);
+        $course1 = $this->getDataGenerator()->create_course();
+        $course1context = context_course::instance($course1->id);
+        $course2 = $this->getDataGenerator()->create_course();
+        $course2context = context_course::instance($course2->id);
+        $course3 = $this->getDataGenerator()->create_course();
+        $course3context = context_course::instance($course3->id);
+        $grouprecord = (object)[
+            'courseid' => $course3->id,
+            'name' => 'test_group'
+        ];
+        $course3group = $this->getDataGenerator()->create_group($grouprecord);
+
+        // Add Category Calendar Events for Category.
+        $this->setUser($user1);
+        $this->create_test_standard_calendar_event('category', $user1->id, time(), '',
+                $category->id);
+        $this->setUser($user2);
+        $this->create_test_standard_calendar_event('category', $user2->id, time(), '',
+                $category->id);
+
+        // Add User Calendar Events for user1 and user2.
+        $this->setUser($user1);
+        $this->create_test_standard_calendar_event('user', $user1->id, time(), '');
+        $this->create_test_standard_calendar_event('user', $user1->id, time(), '',
+                0, $course1->id);
+        $this->create_test_standard_calendar_event('user', $user1->id, time(), '',
+                0, $course2->id);
+        $this->setUser($user2);
+        $this->create_test_standard_calendar_event('user', $user2->id, time(), '',
+            0, $course1->id);
+
+        // Add a Course Calendar Events for Course 1.
+        $this->setUser($user1);
+        $this->create_test_standard_calendar_event('course', $user1->id, time(), '',
+                0, $course1->id);
+        $this->setUser($user2);
+        $this->create_test_standard_calendar_event('course', $user2->id, time(), '',
+            0, $course1->id);
+
+        // Add a Course Assignment Action Calendar Event for Course 2.
+        $generator = $this->getDataGenerator()->get_plugin_generator('mod_assign');
+        $params['course'] = $course2->id;
+        $params['assignsubmission_onlinetext_enabled'] = 1;
+        $instance = $generator->create_instance($params);
+        $cm = get_coursemodule_from_instance('assign', $instance->id);
+        $modulecontext = context_module::instance($cm->id);
+        $assign = new assign($modulecontext, $cm, $course2);
+        $this->setUser($user2);
+        $this->create_test_action_calendar_event('duedate', $course2->id, $instance->id,
+                'assign', $user2->id, time());
+        $this->create_test_action_calendar_event('gradingduedate', $course2->id, $instance->id,
+                'assign', $user2->id, time());
+
+        // Add a Calendar Subscription and Group Calendar Event to Course 3.
+        $this->create_test_standard_calendar_event('group', $user2->id, time(), '', 0,
+                $course3->id, $course3group->id);
+        $this->setUser($user3);
+        $this->create_test_calendar_subscription('course', 'https://calendar.google.com/', $user3->id,
+                0, $course3->id);
+
+        // The user list for usercontext1 should return user1.
+        $userlist1 = new \core_privacy\local\request\userlist($usercontext1, $component);
+        provider::get_users_in_context($userlist1);
+        $this->assertCount(1, $userlist1);
+        $this->assertTrue(in_array($user1->id, $userlist1->get_userids()));
+        // The user list for usercontext2 should return user2.
+        $userlist2 = new \core_privacy\local\request\userlist($usercontext2, $component);
+        provider::get_users_in_context($userlist2);
+        $this->assertCount(1, $userlist2);
+        $this->assertTrue(in_array($user2->id, $userlist2->get_userids()));
+        // The user list for course1context should return user1 and user2.
+        $userlist3 = new \core_privacy\local\request\userlist($course1context, $component);
+        provider::get_users_in_context($userlist3);
+        $this->assertCount(2, $userlist3);
+        $this->assertTrue(in_array($user1->id, $userlist3->get_userids()));
+        $this->assertTrue(in_array($user2->id, $userlist3->get_userids()));
+        // The user list for course2context should not return any users.
+        $userlist4 = new \core_privacy\local\request\userlist($course2context, $component);
+        provider::get_users_in_context($userlist4);
+        $this->assertCount(0, $userlist4);
+        // The user list for course3context should return user2 and user3.
+        $userlist5 = new \core_privacy\local\request\userlist($course3context, $component);
+        provider::get_users_in_context($userlist5);
+        $this->assertCount(2, $userlist5);
+        $this->assertTrue(in_array($user2->id, $userlist5->get_userids()));
+        $this->assertTrue(in_array($user3->id, $userlist5->get_userids()));
+        // The user list for categorycontext should return user1 and user2.
+        $userlist6 = new \core_privacy\local\request\userlist($categorycontext, $component);
+        provider::get_users_in_context($userlist6);
+        $this->assertCount(2, $userlist6);
+        $this->assertTrue(in_array($user1->id, $userlist6->get_userids()));
+        $this->assertTrue(in_array($user2->id, $userlist6->get_userids()));
+        // The user list for modulecontext should return user2.
+        $userlist7 = new \core_privacy\local\request\userlist($modulecontext, $component);
+        provider::get_users_in_context($userlist7);
+        $this->assertCount(1, $userlist7);
+        $this->assertTrue(in_array($user2->id, $userlist7->get_userids()));
+        // The user list for usercontext3 should not return any users.
+        $userlist8 = new \core_privacy\local\request\userlist($usercontext3, $component);
+        provider::get_users_in_context($userlist8);
+        $this->assertCount(0, $userlist8);
+    }
+
+    /**
+     * Test that data for users in approved userlist is deleted.
+     */
+    public function test_delete_data_for_users() {
+        $component = 'core_calendar';
+
+        // Create user1 to create Calendar Events and Subscriptions.
+        $user1 = $this->getDataGenerator()->create_user();
+        $usercontext1 = context_user::instance($user1->id);
+        // Create user2 to create Calendar Events and Subscriptions.
+        $user2 = $this->getDataGenerator()->create_user();
+        $usercontext2 = context_user::instance($user2->id);
+        // Create user3 to create Calendar Events and Subscriptions.
+        $user3 = $this->getDataGenerator()->create_user();
+        $usercontext3 = context_user::instance($user3->id);
+
+        // Create a Category and Courses to assign Calendar Events and Subscriptions.
+        $category = $this->getDataGenerator()->create_category();
+        $categorycontext = context_coursecat::instance($category->id);
+        $course1 = $this->getDataGenerator()->create_course();
+        $course1context = context_course::instance($course1->id);
+        $course2 = $this->getDataGenerator()->create_course();
+        $course2context = context_course::instance($course2->id);
+        $course3 = $this->getDataGenerator()->create_course();
+        $course3context = context_course::instance($course3->id);
+        $grouprecord = (object)[
+            'courseid' => $course3->id,
+            'name' => 'test_group'
+        ];
+        $course3group = $this->getDataGenerator()->create_group($grouprecord);
+
+        // Add Category Calendar Events for Category.
+        $this->setUser($user1);
+        $this->create_test_standard_calendar_event('category', $user1->id, time(), '',
+            $category->id);
+        $this->setUser($user2);
+        $this->create_test_standard_calendar_event('category', $user2->id, time(), '',
+            $category->id);
+
+        // Add User Calendar Events for user1 and user2.
+        $this->setUser($user1);
+        $this->create_test_standard_calendar_event('user', $user1->id, time(), '');
+        $this->create_test_standard_calendar_event('user', $user1->id, time(), '',
+            0, $course1->id);
+        $this->create_test_standard_calendar_event('user', $user1->id, time(), '',
+            0, $course2->id);
+        $this->setUser($user2);
+        $this->create_test_standard_calendar_event('user', $user2->id, time(), '',
+            0, $course1->id);
+
+        // Add a Course Calendar Events for Course 1.
+        $this->setUser($user1);
+        $this->create_test_standard_calendar_event('course', $user1->id, time(), '',
+            0, $course1->id);
+        $this->setUser($user2);
+        $this->create_test_standard_calendar_event('course', $user2->id, time(), '',
+            0, $course1->id);
+
+        // Add a Course Assignment Action Calendar Event for Course 2.
+        $generator = $this->getDataGenerator()->get_plugin_generator('mod_assign');
+        $params['course'] = $course2->id;
+        $params['assignsubmission_onlinetext_enabled'] = 1;
+        $instance = $generator->create_instance($params);
+        $cm = get_coursemodule_from_instance('assign', $instance->id);
+        $modulecontext = context_module::instance($cm->id);
+        $assign = new assign($modulecontext, $cm, $course2);
+        $this->setUser($user2);
+        $this->create_test_action_calendar_event('duedate', $course2->id, $instance->id,
+            'assign', $user2->id, time());
+        $this->create_test_action_calendar_event('gradingduedate', $course2->id, $instance->id,
+            'assign', $user2->id, time());
+
+        // Add a Calendar Subscription and Group Calendar Event to Course 3.
+        $this->create_test_standard_calendar_event('group', $user2->id, time(), '', 0,
+            $course3->id, $course3group->id);
+        $this->setUser($user3);
+        $this->create_test_calendar_subscription('course', 'https://calendar.google.com/', $user3->id,
+            0, $course3->id);
+
+        // The user list for usercontext1 should return user1.
+        $userlist1 = new \core_privacy\local\request\userlist($usercontext1, $component);
+        provider::get_users_in_context($userlist1);
+        $this->assertCount(1, $userlist1);
+        // The user list for usercontext2 should return user2.
+        $userlist2 = new \core_privacy\local\request\userlist($usercontext2, $component);
+        provider::get_users_in_context($userlist2);
+        $this->assertCount(1, $userlist2);
+        // The user list for course1context should return user1 and user2.
+        $userlist3 = new \core_privacy\local\request\userlist($course1context, $component);
+        provider::get_users_in_context($userlist3);
+        $this->assertCount(2, $userlist3);
+        // The user list for course2context should not return any users.
+        $userlist4 = new \core_privacy\local\request\userlist($course2context, $component);
+        provider::get_users_in_context($userlist4);
+        $this->assertCount(0, $userlist4);
+        // The user list for course3context should return user2 and user3.
+        $userlist5 = new \core_privacy\local\request\userlist($course3context, $component);
+        provider::get_users_in_context($userlist5);
+        $this->assertCount(2, $userlist5);
+        // The user list for categorycontext should return user1 and user2.
+        $userlist6 = new \core_privacy\local\request\userlist($categorycontext, $component);
+        provider::get_users_in_context($userlist6);
+        $this->assertCount(2, $userlist6);
+        // The user list for modulecontext should return user2.
+        $userlist7 = new \core_privacy\local\request\userlist($modulecontext, $component);
+        provider::get_users_in_context($userlist7);
+        $this->assertCount(1, $userlist7);
+        // The user list for usercontext3 should not return any users.
+        $userlist8 = new \core_privacy\local\request\userlist($usercontext3, $component);
+        provider::get_users_in_context($userlist8);
+        $this->assertCount(0, $userlist8);
+
+        // Convert $userlist1 into an approved_contextlist.
+        $approvedlist1 = new approved_userlist($usercontext1, $component, $userlist1->get_userids());
+        // Delete using delete_data_for_user.
+        provider::delete_data_for_users($approvedlist1);
+        // The user list for usercontext1 should not return any users.
+        $userlist1 = new \core_privacy\local\request\userlist($usercontext1, $component);
+        provider::get_users_in_context($userlist1);
+        $this->assertCount(0, $userlist1);
+        // The user list for usercontext2 should still return users2.
+        $userlist2 = new \core_privacy\local\request\userlist($usercontext2, $component);
+        provider::get_users_in_context($userlist2);
+        $this->assertCount(1, $userlist2);
+
+        // Convert $userlist3 into an approved_contextlist.
+        // Pass an empty array as a value for the approved user list.
+        $approvedlist2 = new approved_userlist($course1context, $component, []);
+        // Delete using delete_data_for_user.
+        provider::delete_data_for_users($approvedlist2);
+        // The user list for course1context should return user1 and user2.
+        $userlist3 = new \core_privacy\local\request\userlist($course1context, $component);
+        provider::get_users_in_context($userlist3);
+        $this->assertCount(2, $userlist3);
+        $this->assertTrue(in_array($user1->id, $userlist3->get_userids()));
+        $this->assertTrue(in_array($user2->id, $userlist3->get_userids()));
+
+        // Convert $userlist3 into an approved_contextlist.
+        // Pass the ID of user1 as a value for the approved user list.
+        $approvedlist2 = new approved_userlist($course1context, $component, [$user1->id]);
+        // Delete using delete_data_for_user.
+        provider::delete_data_for_users($approvedlist2);
+        // The user list for course1context should return user2.
+        $userlist3 = new \core_privacy\local\request\userlist($course1context, $component);
+        provider::get_users_in_context($userlist3);
+        $this->assertCount(1, $userlist3);
+        $this->assertTrue(in_array($user2->id, $userlist3->get_userids()));
+
+        // The user list for course3context should still return user2 and user3.
+        $userlist5 = new \core_privacy\local\request\userlist($course3context, $component);
+        provider::get_users_in_context($userlist5);
+        $this->assertCount(2, $userlist5);
+
+        // Convert $userlist6 into an approved_contextlist.
+        $approvedlist3 = new approved_userlist($categorycontext, $component, $userlist6->get_userids());
+        // Delete using delete_data_for_user.
+        provider::delete_data_for_users($approvedlist3);
+        // The user list for categorycontext should not return any users.
+        $userlist6 = new \core_privacy\local\request\userlist($categorycontext, $component);
+        provider::get_users_in_context($userlist6);
+        $this->assertCount(0, $userlist6);
+
+        // Convert $userlist7 into an approved_contextlist.
+        $approvedlist4 = new approved_userlist($modulecontext, $component, $userlist7->get_userids());
+        // Delete using delete_data_for_user.
+        provider::delete_data_for_users($approvedlist4);
+        // The user list for modulecontext should not return any users.
+        $userlist7 = new \core_privacy\local\request\userlist($modulecontext, $component);
+        provider::get_users_in_context($userlist7);
+        $this->assertCount(0, $userlist7);
+    }
+
     // Start of helper functions.
 
     /**
diff --git a/course/amd/build/events.min.js b/course/amd/build/events.min.js
new file mode 100644 (file)
index 0000000..4ea5c9e
Binary files /dev/null and b/course/amd/build/events.min.js differ
diff --git a/course/amd/src/events.js b/course/amd/src/events.js
new file mode 100644 (file)
index 0000000..7609702
--- /dev/null
@@ -0,0 +1,29 @@
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Contain the events the course component can trigger.
+ *
+ * @module     core_course/events
+ * @package    core_course
+ * @copyright  2018 Simey Lameze <simey@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+define([], function() {
+    return {
+        favourited: 'core_course:favourited',
+        unfavorited: 'core_course:unfavorited',
+    };
+});
\ No newline at end of file
index 9714b2d..7b13d65 100644 (file)
@@ -71,7 +71,8 @@ class course_summary_exporter extends \core\external\exporter {
             'courseimage' => $courseimage,
             'progress' => $progress,
             'hasprogress' => $hasprogress,
-            'isfavourite' => $this->related['isfavourite']
+            'isfavourite' => $this->related['isfavourite'],
+            'hidden' => boolval(get_user_preferences('block_myoverview_hidden_course_' . $this->data->id, 0))
         );
     }
 
@@ -137,6 +138,9 @@ class course_summary_exporter extends \core\external\exporter {
             ),
             'isfavourite' => array(
                 'type' => PARAM_BOOL
+            ),
+            'hidden' => array(
+                'type' => PARAM_BOOL
             )
         );
     }
index acc1198..e8b1e43 100644 (file)
@@ -3698,6 +3698,8 @@ class core_course_external extends external_api {
                 break;
             case COURSE_FAVOURITES:
                 break;
+            case COURSE_TIMELINE_HIDDEN:
+                break;
             default:
                 throw new invalid_parameter_exception('Invalid classification');
         }
@@ -3706,7 +3708,17 @@ class core_course_external extends external_api {
 
         $requiredproperties = course_summary_exporter::define_properties();
         $fields = join(',', array_keys($requiredproperties));
-        $courses = course_get_enrolled_courses_for_logged_in_user(0, $offset, $sort, $fields);
+        $hiddencourses = get_hidden_courses_on_timeline();
+        $courses = [];
+
+        // If the timeline requires the hidden courses then restrict the result to only $hiddencourses else exclude.
+        if ($classification == COURSE_TIMELINE_HIDDEN) {
+            $courses = course_get_enrolled_courses_for_logged_in_user(0, $offset, $sort, $fields,
+                COURSE_DB_QUERY_LIMIT, $hiddencourses);
+        } else {
+            $courses = course_get_enrolled_courses_for_logged_in_user(0, $offset, $sort, $fields,
+                COURSE_DB_QUERY_LIMIT, [], $hiddencourses);
+        }
 
         $favouritecourseids = [];
         $ufservice = \core_favourites\service_factory::get_service_for_user_context(\context_user::instance($USER->id));
@@ -3725,7 +3737,6 @@ class core_course_external extends external_api {
                 $favouritecourseids,
                 $limit
             );
-
         } else {
             list($filteredcourses, $processedcount) = course_filter_courses_by_timeline_classification(
                 $courses,
index f063649..d7b9245 100644 (file)
@@ -60,6 +60,7 @@ define('COURSE_TIMELINE_PAST', 'past');
 define('COURSE_TIMELINE_INPROGRESS', 'inprogress');
 define('COURSE_TIMELINE_FUTURE', 'future');
 define('COURSE_FAVOURITES', 'favourites');
+define('COURSE_TIMELINE_HIDDEN', 'hidden');
 define('COURSE_DB_QUERY_LIMIT', 1000);
 
 function make_log_url($module, $url) {
@@ -4190,6 +4191,8 @@ function course_classify_courses_for_timeline(array $courses) {
  * @param string|null $sort SQL string for sorting
  * @param string|null $fields SQL string for fields to be returned
  * @param int $dbquerylimit The number of records to load per DB request
+ * @param array $includecourses courses ids to be restricted
+ * @param array $hiddencourses courses ids to be excluded
  * @return Generator
  */
 function course_get_enrolled_courses_for_logged_in_user(
@@ -4197,14 +4200,16 @@ function course_get_enrolled_courses_for_logged_in_user(
     int $offset = 0,
     string $sort = null,
     string $fields = null,
-    int $dbquerylimit = COURSE_DB_QUERY_LIMIT
+    int $dbquerylimit = COURSE_DB_QUERY_LIMIT,
+    array $includecourses = [],
+    array $hiddencourses = []
 ) : Generator {
 
     $haslimit = !empty($limit);
     $recordsloaded = 0;
     $querylimit = (!$haslimit || $limit > $dbquerylimit) ? $dbquerylimit : $limit;
 
-    while ($courses = enrol_get_my_courses($fields, $sort, $querylimit, [], false, $offset)) {
+    while ($courses = enrol_get_my_courses($fields, $sort, $querylimit, $includecourses, false, $offset, $hiddencourses)) {
         yield from $courses;
 
         $recordsloaded += $querylimit;
@@ -4242,7 +4247,8 @@ function course_filter_courses_by_timeline_classification(
 ) : array {
 
     if (!in_array($classification,
-            [COURSE_TIMELINE_ALL, COURSE_TIMELINE_PAST, COURSE_TIMELINE_INPROGRESS, COURSE_TIMELINE_FUTURE])) {
+            [COURSE_TIMELINE_ALL, COURSE_TIMELINE_PAST, COURSE_TIMELINE_INPROGRESS,
+                COURSE_TIMELINE_FUTURE, COURSE_TIMELINE_HIDDEN])) {
         $message = 'Classification must be one of COURSE_TIMELINE_ALL, COURSE_TIMELINE_PAST, '
             . 'COURSE_TIMELINE_INPROGRESS or COURSE_TIMELINE_FUTURE';
         throw new moodle_exception($message);
@@ -4254,8 +4260,11 @@ function course_filter_courses_by_timeline_classification(
 
     foreach ($courses as $course) {
         $numberofcoursesprocessed++;
+        $pref = get_user_preferences('block_myoverview_hidden_course_' . $course->id, 0);
 
-        if ($classification == COURSE_TIMELINE_ALL || $classification == course_classify_for_timeline($course)) {
+        // Added as of MDL-63457 toggle viewability for each user.
+        if (($classification == COURSE_TIMELINE_HIDDEN && $pref) ||
+            (($classification == COURSE_TIMELINE_ALL || $classification == course_classify_for_timeline($course)) && !$pref)) {
             $filteredcourses[] = $course;
             $filtermatches++;
         }
@@ -4494,3 +4503,29 @@ function can_download_from_backup_filearea($filearea, \context $context, stdClas
     }
     return $candownload;
 }
+
+/**
+ * Get a list of hidden courses
+ *
+ * @param int|object|null $user User override to get the filter from. Defaults to current user
+ * @return array $ids List of hidden courses
+ * @throws coding_exception
+ */
+function get_hidden_courses_on_timeline($user = null) {
+    global $USER;
+
+    if (empty($user)) {
+        $user = $USER->id;
+    }
+
+    $preferences = get_user_preferences(null, null, $user);
+    $ids = [];
+    foreach ($preferences as $key => $value) {
+        if (preg_match('/block_myoverview_hidden_course_(\d)+/', $key)) {
+            $id = preg_split('/block_myoverview_hidden_course_/', $key);
+            $ids[] = $id[1];
+        }
+    }
+
+    return $ids;
+}
@@ -15,7 +15,7 @@
     along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
 }}
 {{!
-    @template block_myoverview/favourite-icon
+    @template core_course/favouriteicon
 
     This template renders the favourite icon for a course.
 
             data-region="is-favourite"
             aria-hidden="{{^isfavourite}}true{{/isfavourite}}{{#isfavourite}}false{{/isfavourite}}"
             >
-            {{#pix}} i/star, core, {{#str}} favourite, block_myoverview {{/str}} {{/pix}}
-            <span class="sr-only">{{#str}} aria:favourite, block_myoverview {{/str}}</span>
+            {{#pix}} i/star, core, {{#str}} favourite, core_course {{/str}} {{/pix}}
+            <span class="sr-only">{{#str}} aria:favourite, core_course {{/str}}</span>
         </span>
         <span
             {{#isfavourite}}class="hidden"{{/isfavourite}}
             data-region="not-favourite"
             aria-hidden="{{^isfavourite}}false{{/isfavourite}}{{#isfavourite}}true{{/isfavourite}}"
             >
-            {{#pix}} i/empty, core, {{#str}} notfavourite, block_myoverview {{/str}} {{/pix}}
+            {{#pix}} i/empty, core, {{#str}} notfavourite, core_course {{/str}} {{/pix}}
         </span>
     </div>
 </div>
index 1bdf3b5..37ca662 100644 (file)
@@ -4718,6 +4718,218 @@ class core_course_courselib_testcase extends advanced_testcase {
         $this->assertEquals($expectedprocessedcount, $processedcount);
     }
 
+    /**
+     * Test cases for the course_filter_courses_by_timeline_classification w/ hidden courses tests.
+     */
+    public function get_course_filter_courses_by_timeline_classification_hidden_courses_test_cases() {
+        $now = time();
+        $day = 86400;
+
+        $coursedata = [
+            [
+                'shortname' => 'apast',
+                'startdate' => $now - ($day * 2),
+                'enddate' => $now - $day
+            ],
+            [
+                'shortname' => 'bpast',
+                'startdate' => $now - ($day * 2),
+                'enddate' => $now - $day
+            ],
+            [
+                'shortname' => 'cpast',
+                'startdate' => $now - ($day * 2),
+                'enddate' => $now - $day
+            ],
+            [
+                'shortname' => 'dpast',
+                'startdate' => $now - ($day * 2),
+                'enddate' => $now - $day
+            ],
+            [
+                'shortname' => 'epast',
+                'startdate' => $now - ($day * 2),
+                'enddate' => $now - $day
+            ],
+            [
+                'shortname' => 'ainprogress',
+                'startdate' => $now - $day,
+                'enddate' => $now + $day
+            ],
+            [
+                'shortname' => 'binprogress',
+                'startdate' => $now - $day,
+                'enddate' => $now + $day
+            ],
+            [
+                'shortname' => 'cinprogress',
+                'startdate' => $now - $day,
+                'enddate' => $now + $day
+            ],
+            [
+                'shortname' => 'dinprogress',
+                'startdate' => $now - $day,
+                'enddate' => $now + $day
+            ],
+            [
+                'shortname' => 'einprogress',
+                'startdate' => $now - $day,
+                'enddate' => $now + $day
+            ],
+            [
+                'shortname' => 'afuture',
+                'startdate' => $now + $day
+            ],
+            [
+                'shortname' => 'bfuture',
+                'startdate' => $now + $day
+            ],
+            [
+                'shortname' => 'cfuture',
+                'startdate' => $now + $day
+            ],
+            [
+                'shortname' => 'dfuture',
+                'startdate' => $now + $day
+            ],
+            [
+                'shortname' => 'efuture',
+                'startdate' => $now + $day
+            ]
+        ];
+
+        // Raw enrolled courses result set should be returned in this order:
+        // afuture, ainprogress, apast, bfuture, binprogress, bpast, cfuture, cinprogress, cpast,
+        // dfuture, dinprogress, dpast, efuture, einprogress, epast
+        //
+        // By classification the offset values for each record should be:
+        // COURSE_TIMELINE_FUTURE
+        // 0 (afuture), 3 (bfuture), 6 (cfuture), 9 (dfuture), 12 (efuture)
+        // COURSE_TIMELINE_INPROGRESS
+        // 1 (ainprogress), 4 (binprogress), 7 (cinprogress), 10 (dinprogress), 13 (einprogress)
+        // COURSE_TIMELINE_PAST
+        // 2 (apast), 5 (bpast), 8 (cpast), 11 (dpast), 14 (epast).
+        return [
+            'empty set' => [
+                'coursedata' => [],
+                'classification' => COURSE_TIMELINE_FUTURE,
+                'limit' => 2,
+                'offset' => 0,
+                'expectedcourses' => [],
+                'expectedprocessedcount' => 0,
+                'hiddencourse' => ''
+            ],
+            // COURSE_TIMELINE_FUTURE.
+            'future not limit no offset' => [
+                'coursedata' => $coursedata,
+                'classification' => COURSE_TIMELINE_FUTURE,
+                'limit' => 0,
+                'offset' => 0,
+                'expectedcourses' => ['afuture', 'cfuture', 'dfuture', 'efuture'],
+                'expectedprocessedcount' => 15,
+                'hiddencourse' => 'bfuture'
+            ],
+            'future no offset' => [
+                'coursedata' => $coursedata,
+                'classification' => COURSE_TIMELINE_FUTURE,
+                'limit' => 2,
+                'offset' => 0,
+                'expectedcourses' => ['afuture', 'cfuture'],
+                'expectedprocessedcount' => 7,
+                'hiddencourse' => 'bfuture'
+            ],
+            'future offset' => [
+                'coursedata' => $coursedata,
+                'classification' => COURSE_TIMELINE_FUTURE,
+                'limit' => 2,
+                'offset' => 2,
+                'expectedcourses' => ['bfuture', 'dfuture'],
+                'expectedprocessedcount' => 8,
+                'hiddencourse' => 'cfuture'
+            ],
+            'future exact limit' => [
+                'coursedata' => $coursedata,
+                'classification' => COURSE_TIMELINE_FUTURE,
+                'limit' => 5,
+                'offset' => 0,
+                'expectedcourses' => ['afuture', 'cfuture', 'dfuture', 'efuture'],
+                'expectedprocessedcount' => 15,
+                'hiddencourse' => 'bfuture'
+            ],
+            'future limit less results' => [
+                'coursedata' => $coursedata,
+                'classification' => COURSE_TIMELINE_FUTURE,
+                'limit' => 10,
+                'offset' => 0,
+                'expectedcourses' => ['afuture', 'cfuture', 'dfuture', 'efuture'],
+                'expectedprocessedcount' => 15,
+                'hiddencourse' => 'bfuture'
+            ],
+            'future limit less results with offset' => [
+                'coursedata' => $coursedata,
+                'classification' => COURSE_TIMELINE_FUTURE,
+                'limit' => 10,
+                'offset' => 5,
+                'expectedcourses' => ['cfuture', 'efuture'],
+                'expectedprocessedcount' => 10,
+                'hiddencourse' => 'dfuture'
+            ],
+        ];
+    }
+
+    /**
+     * Test the course_filter_courses_by_timeline_classification function hidden courses.
+     *
+     * @dataProvider get_course_filter_courses_by_timeline_classification_hidden_courses_test_cases()
+     * @param array $coursedata Course test data to create.
+     * @param string $classification Timeline classification.
+     * @param int $limit Maximum number of results to return.
+     * @param int $offset Results to skip at the start of the result set.
+     * @param string[] $expectedcourses Expected courses in results.
+     * @param int $expectedprocessedcount Expected number of course records to be processed.
+     * @param int $hiddencourse The course to hide as part of this process
+     */
+    public function test_course_filter_courses_by_timeline_classification_with_hidden_courses(
+        $coursedata,
+        $classification,
+        $limit,
+        $offset,
+        $expectedcourses,
+        $expectedprocessedcount,
+        $hiddencourse
+    ) {
+        $this->resetAfterTest();
+        $generator = $this->getDataGenerator();
+        $student = $generator->create_user();
+        $this->setUser($student);
+
+        $courses = array_map(function($coursedata) use ($generator, $hiddencourse) {
+            $course = $generator->create_course($coursedata);
+            if ($course->shortname == $hiddencourse) {
+                set_user_preference('block_myoverview_hidden_course_' . $course->id, true);
+            }
+            return $course;
+        }, $coursedata);
+
+        foreach ($courses as $course) {
+            $generator->enrol_user($student->id, $course->id, 'student');
+        }
+
+        $coursesgenerator = course_get_enrolled_courses_for_logged_in_user(0, $offset, 'shortname ASC', 'shortname');
+        list($result, $processedcount) = course_filter_courses_by_timeline_classification(
+            $coursesgenerator,
+            $classification,
+            $limit
+        );
+
+        $actual = array_map(function($course) {
+            return $course->shortname;
+        }, $result);
+
+        $this->assertEquals($expectedcourses, $actual);
+        $this->assertEquals($expectedprocessedcount, $processedcount);
+    }
+
 
     /**
      * Testing core_course_core_calendar_get_valid_event_timestart_range when the course has no end date.
index c46cfaf..0013e74 100644 (file)
@@ -11,6 +11,7 @@ information provided here is intended especially for developers.
    - customdata (module custom data (JSON encoded))
    - completion (to indicate if completion is enabled or not)
    - completiondata (completion status for the current user in the module)
+ * External function core_group_external::get_course_user_groups now can return all user courses group information.
 
 === 3.5 ===
 
index 34b5b48..1f3ff6b 100644 (file)
@@ -604,6 +604,60 @@ class core_enrollib_testcase extends advanced_testcase {
         $this->assertEquals($course2->id, $courses[$course2->id]->id);
     }
 
+    /**
+     * Tests the enrol_get_my_courses function when using the $includehidden parameter, which
+     * should remove any courses hidden from the user's timeline
+     *
+     * @throws coding_exception
+     * @throws dml_exception
+     */
+    public function test_enrol_get_my_courses_include_hidden() {
+        global $DB, $CFG;
+
+        $this->resetAfterTest(true);
+
+        // Create test user and 4 courses, two of which have guest access enabled.
+        $user = $this->getDataGenerator()->create_user();
+        $course1 = $this->getDataGenerator()->create_course(
+            (object)array('shortname' => 'X',
+                'enrol_guest_status_0' => ENROL_INSTANCE_DISABLED,
+                'enrol_guest_password_0' => ''));
+        $course2 = $this->getDataGenerator()->create_course(
+            (object)array('shortname' => 'Z',
+                'enrol_guest_status_0' => ENROL_INSTANCE_ENABLED,
+                'enrol_guest_password_0' => ''));
+        $course3 = $this->getDataGenerator()->create_course(
+            (object)array('shortname' => 'Y',
+                'enrol_guest_status_0' => ENROL_INSTANCE_ENABLED,
+                'enrol_guest_password_0' => 'frog'));
+        $course4 = $this->getDataGenerator()->create_course(
+            (object)array('shortname' => 'W',
+                'enrol_guest_status_0' => ENROL_INSTANCE_DISABLED,
+                'enrol_guest_password_0' => ''));
+
+        // User is enrolled in first course.
+        $this->getDataGenerator()->enrol_user($user->id, $course1->id);
+        $this->getDataGenerator()->enrol_user($user->id, $course2->id);
+        $this->getDataGenerator()->enrol_user($user->id, $course3->id);
+        $this->getDataGenerator()->enrol_user($user->id, $course4->id);
+
+        // Check enrol_get_my_courses basic use (without include hidden provided).
+        $this->setUser($user);
+        $courses = enrol_get_my_courses();
+        $this->assertEquals([$course4->id, $course3->id, $course2->id, $course1->id], array_keys($courses));
+
+        // Hide a course.
+        set_user_preference('block_myoverview_hidden_course_' . $course3->id, true);
+
+        // Hidden course shouldn't be returned.
+        $courses = enrol_get_my_courses(null, null, 0, [], false, 0, [$course3->id]);
+        $this->assertEquals([$course4->id, $course2->id, $course1->id], array_keys($courses));
+
+        // Offset should take into account hidden course.
+        $courses = enrol_get_my_courses(null, null, 0, [], false, 2, [$course3->id]);
+        $this->assertEquals([$course1->id], array_keys($courses));
+    }
+
     /**
      * Tests the enrol_get_my_courses function when using the $allaccessible parameter, which
      * includes a wider range of courses (enrolled courses + other accessible ones).
index 4c04acd..9ed2492 100644 (file)
@@ -1180,8 +1180,9 @@ class core_group_external extends external_api {
     public static function get_course_user_groups_parameters() {
         return new external_function_parameters(
             array(
-                'courseid' => new external_value(PARAM_INT, 'id of course'),
-                'userid' => new external_value(PARAM_INT, 'id of user'),
+                'courseid' => new external_value(PARAM_INT,
+                    'Id of course (empty or 0 for all the courses where the user is enrolled).', VALUE_DEFAULT, 0),
+                'userid' => new external_value(PARAM_INT, 'Id of user (empty or 0 for current user).', VALUE_DEFAULT, 0),
                 'groupingid' => new external_value(PARAM_INT, 'returns only groups in the specified grouping', VALUE_DEFAULT, 0)
             )
         );
@@ -1197,7 +1198,7 @@ class core_group_external extends external_api {
      * @return array of group objects (id, name, description, format) and possible warnings.
      * @since Moodle 2.9
      */
-    public static function get_course_user_groups($courseid, $userid, $groupingid = 0) {
+    public static function get_course_user_groups($courseid = 0, $userid = 0, $groupingid = 0) {
         global $USER;
 
         // Warnings array, it can be empty at the end but is mandatory.
@@ -1209,43 +1210,62 @@ class core_group_external extends external_api {
             'groupingid' => $groupingid
         );
         $params = self::validate_parameters(self::get_course_user_groups_parameters(), $params);
+
         $courseid = $params['courseid'];
         $userid = $params['userid'];
         $groupingid = $params['groupingid'];
 
-        // Validate course and user. get_course throws an exception if the course does not exists.
-        $course = get_course($courseid);
-        $user = core_user::get_user($userid, '*', MUST_EXIST);
-        core_user::require_active_user($user);
+        // Validate user.
+        if (empty($userid)) {
+            $userid = $USER->id;
+        } else {
+            $user = core_user::get_user($userid, '*', MUST_EXIST);
+            core_user::require_active_user($user);
+        }
+
+        // Get courses.
+        if (empty($courseid)) {
+            $courses = enrol_get_users_courses($userid, true);
+            $checkenrolments = false;   // No need to check enrolments here since they are my courses.
+        } else {
+            $courses = array($courseid => get_course($courseid));
+            $checkenrolments = true;
+        }
 
         // Security checks.
-        $context = context_course::instance($course->id);
-        self::validate_context($context);
+        list($courses, $warnings) = external_util::validate_courses(array_keys($courses), $courses, true);
 
-         // Check if we have permissions for retrieve the information.
-        if ($user->id != $USER->id) {
-            if (!has_capability('moodle/course:managegroups', $context)) {
-                throw new moodle_exception('accessdenied', 'admin');
+        $usergroups = array();
+        foreach ($courses as $course) {
+             // Check if we have permissions for retrieve the information.
+            if ($userid != $USER->id && !has_capability('moodle/course:managegroups', $course->context)) {
+                $warnings[] = array(
+                    'item' => 'course',
+                    'itemid' => $course->id,
+                    'warningcode' => 'cannotmanagegroups',
+                    'message' => "User $USER->id cannot manage groups in course $course->id",
+                );
+                continue;
             }
-            // Validate if the user is enrolled in the course.
-            if (!is_enrolled($context, $user->id)) {
+
+            // Check if the user being check is enrolled in the given course.
+            if ($checkenrolments && !is_enrolled($course->context, $userid)) {
                 // We return a warning because the function does not fail for not enrolled users.
-                $warning['item'] = 'course';
-                $warning['itemid'] = $course->id;
-                $warning['warningcode'] = '1';
-                $warning['message'] = "User $user->id is not enrolled in course $course->id";
-                $warnings[] = $warning;
+                $warnings[] = array(
+                    'item' => 'course',
+                    'itemid' => $course->id,
+                    'warningcode' => 'notenrolled',
+                    'message' => "User $userid is not enrolled in course $course->id",
+                );
             }
-        }
 
-        $usergroups = array();
-        if (empty($warnings)) {
-            $groups = groups_get_all_groups($course->id, $user->id, 0, 'g.id, g.name, g.description, g.descriptionformat, g.idnumber');
+            $groups = groups_get_all_groups($course->id, $userid, $groupingid,
+                'g.id, g.name, g.description, g.descriptionformat, g.idnumber');