Merge branch 'MDL-68572-master' of https://github.com/catalyst/moodle
authorJake Dallimore <jake@moodle.com>
Thu, 7 May 2020 08:10:20 +0000 (16:10 +0800)
committerSara Arjona <sara@moodle.com>
Thu, 7 May 2020 16:30:47 +0000 (18:30 +0200)
141 files changed:
admin/renderer.php
admin/tool/mobile/classes/api.php
admin/tool/mobile/lang/en/tool_mobile.php
admin/tool/mobile/settings.php
availability/condition/completion/classes/condition.php
availability/condition/completion/classes/frontend.php
availability/condition/completion/db/caches.php [new file with mode: 0644]
availability/condition/completion/lang/en/availability_completion.php
availability/condition/completion/tests/behat/availability_completion_previous.feature [new file with mode: 0644]
availability/condition/completion/tests/condition_test.php
availability/condition/completion/version.php
availability/tests/fixtures/mock_info_module.php [new file with mode: 0644]
availability/tests/fixtures/mock_info_section.php [new file with mode: 0644]
blocks/recent_activity/renderer.php
blocks/recent_activity/styles.css
contentbank/classes/content.php
contentbank/classes/contentbank.php
contentbank/classes/contenttype.php
contentbank/contenttype/h5p/classes/contenttype.php
contentbank/tests/behat/events.feature [new file with mode: 0644]
contentbank/tests/contentbank_test.php
contentbank/upload.php
course/amd/build/activitychooser.min.js
course/amd/build/activitychooser.min.js.map
course/amd/src/activitychooser.js
course/format/renderer.php
course/format/topics/styles.css [deleted file]
course/format/weeks/styles.css [deleted file]
course/recent.php
course/renderer.php
course/yui/build/moodle-course-dragdrop/moodle-course-dragdrop-debug.js
course/yui/build/moodle-course-dragdrop/moodle-course-dragdrop-min.js
course/yui/build/moodle-course-dragdrop/moodle-course-dragdrop.js
course/yui/src/dragdrop/js/section.js
enrol/database/settings.php
enrol/database/settingslib.php [deleted file]
enrol/database/upgrade.txt
filter/mathjaxloader/db/upgrade.php
filter/mathjaxloader/readme_moodle.txt
filter/mathjaxloader/settings.php
filter/mathjaxloader/version.php
grade/grading/form/guide/lang/en/gradingform_guide.php
grade/grading/form/guide/templates/grades/grader/gradingpanel.mustache
lang/en/admin.php
lang/en/contentbank.php
lib/accesslib.php
lib/classes/dml/sql_join.php
lib/classes/event/contentbank_content_created.php [new file with mode: 0644]
lib/classes/event/contentbank_content_deleted.php [new file with mode: 0644]
lib/classes/event/contentbank_content_updated.php [new file with mode: 0644]
lib/classes/event/contentbank_content_uploaded.php [new file with mode: 0644]
lib/classes/event/contentbank_content_viewed.php [new file with mode: 0644]
lib/editor/atto/plugins/accessibilitychecker/tests/behat/accessibilitychecker.feature
lib/editor/atto/plugins/image/lang/en/atto_image.php
lib/editor/atto/plugins/image/tests/behat/image.feature
lib/editor/atto/plugins/image/yui/build/moodle-atto_image-button/moodle-atto_image-button-debug.js
lib/editor/atto/plugins/image/yui/build/moodle-atto_image-button/moodle-atto_image-button-min.js
lib/editor/atto/plugins/image/yui/build/moodle-atto_image-button/moodle-atto_image-button.js
lib/editor/atto/plugins/image/yui/src/button/js/button.js
lib/editor/atto/plugins/italic/tests/behat/italic.feature
lib/editor/atto/plugins/italic/yui/build/moodle-atto_italic-button/moodle-atto_italic-button-debug.js
lib/editor/atto/plugins/italic/yui/build/moodle-atto_italic-button/moodle-atto_italic-button-min.js
lib/editor/atto/plugins/italic/yui/build/moodle-atto_italic-button/moodle-atto_italic-button.js
lib/editor/atto/plugins/italic/yui/src/button/js/button.js
lib/editor/atto/tests/behat/customtoolbar.feature
lib/editor/atto/tests/behat/disablecontrol.feature
lib/questionlib.php
lib/table/amd/build/dynamic.min.js
lib/table/amd/build/dynamic.min.js.map
lib/table/amd/build/local/dynamic/repository.min.js
lib/table/amd/build/local/dynamic/repository.min.js.map
lib/table/amd/src/dynamic.js
lib/table/amd/src/local/dynamic/repository.js
lib/table/classes/external/dynamic/fetch.php
lib/tablelib.php
lib/tests/accesslib_test.php
lib/tests/event/contentbank_content_created_test.php [new file with mode: 0644]
lib/tests/event/contentbank_content_deleted_test.php [new file with mode: 0644]
lib/tests/event/contentbank_content_updated_test.php [new file with mode: 0644]
lib/tests/event/contentbank_content_uploaded_test.php [new file with mode: 0644]
lib/tests/event/contentbank_content_viewed_test.php [new file with mode: 0644]
lib/tests/questionlib_test.php
media/player/videojs/amd/build/Youtube-lazy.min.js
media/player/videojs/amd/build/Youtube-lazy.min.js.map
media/player/videojs/amd/build/video-lazy.min.js
media/player/videojs/amd/build/video-lazy.min.js.map
media/player/videojs/amd/build/videojs-flash-lazy.min.js
media/player/videojs/amd/build/videojs-flash-lazy.min.js.map
media/player/videojs/amd/src/Youtube-lazy.js
media/player/videojs/amd/src/video-lazy.js
media/player/videojs/amd/src/videojs-flash-lazy.js
media/player/videojs/classes/plugin.php
media/player/videojs/fonts/VideoJS.svg [changed mode: 0644->0755]
media/player/videojs/fonts/VideoJS.ttf [changed mode: 0644->0755]
media/player/videojs/fonts/VideoJS.woff [changed mode: 0644->0755]
media/player/videojs/readme_moodle.txt
media/player/videojs/tests/player_test.php
media/player/videojs/thirdpartylibs.xml
media/player/videojs/videojs/lang/ar.js
media/player/videojs/videojs/lang/ar.json
media/player/videojs/videojs/lang/de.js
media/player/videojs/videojs/lang/de.json
media/player/videojs/videojs/lang/en.js
media/player/videojs/videojs/lang/en.json
media/player/videojs/videojs/lang/fa.js
media/player/videojs/videojs/lang/fa.json
media/player/videojs/videojs/lang/gd.json [changed mode: 0644->0755]
media/player/videojs/videojs/lang/nb.js
media/player/videojs/videojs/lang/nb.json
media/player/videojs/videojs/lang/nn.js
media/player/videojs/videojs/lang/nn.json
message/output/airnotifier/lang/en/message_airnotifier.php
message/output/airnotifier/settings.php
mod/assign/lib.php
mod/chat/lib.php
mod/folder/lib.php
mod/forum/lib.php
mod/glossary/lib.php
mod/h5pactivity/backup/moodle2/backup_h5pactivity_stepslib.php
mod/h5pactivity/classes/local/attempt.php
mod/h5pactivity/classes/privacy/provider.php
mod/h5pactivity/db/install.xml
mod/h5pactivity/db/upgrade.php
mod/h5pactivity/tests/local/attempt_test.php
mod/h5pactivity/tests/restore_test.php
mod/h5pactivity/tests/xapi/handler_test.php
mod/h5pactivity/version.php
mod/scorm/lang/en/scorm.php
mod/scorm/locallib.php
mod/survey/lib.php
mod/wiki/lib.php
mod/workshop/lib.php
portfolio/googledocs/lib.php
portfolio/googledocs/upgrade.txt [new file with mode: 0644]
question/format/gift/format.php
question/format/gift/tests/giftformat_test.php
theme/boost/scss/moodle/admin.scss
theme/boost/scss/moodle/course.scss
theme/boost/style/moodle.css
theme/classic/style/moodle.css
user/tests/behat/view_participants.feature

index 5931a26..45f46a5 100644 (file)
@@ -361,14 +361,15 @@ class core_admin_renderer extends plugin_renderer_base {
         $output = '';
 
         if ($checker->enabled()) {
-            $output .= $this->container_start('checkforupdates');
+            $output .= $this->container_start('checkforupdates mb-4');
             $output .= $this->single_button(
                 new moodle_url($reloadurl, array('fetchupdates' => 1)),
                 get_string('checkforupdates', 'core_plugin')
             );
             if ($timefetched = $checker->get_last_timefetched()) {
                 $timefetched = userdate($timefetched, get_string('strftimedatetime', 'core_langconfig'));
-                $output .= $this->container(get_string('checkforupdateslast', 'core_plugin', $timefetched), 'lasttimefetched');
+                $output .= $this->container(get_string('checkforupdateslast', 'core_plugin', $timefetched),
+                    'lasttimefetched small text-muted mt-1');
             }
             $output .= $this->container_end();
         }
@@ -1045,7 +1046,7 @@ class core_admin_renderer extends plugin_renderer_base {
                 $displayname = new html_table_cell(
                     $icon.
                     html_writer::span($plugin->displayname, 'pluginname').
-                    html_writer::div($plugin->get_dir(), 'plugindir')
+                    html_writer::div($plugin->get_dir(), 'plugindir text-muted small')
                 );
 
                 $versiondb = new html_table_cell($plugin->versiondb);
@@ -1082,7 +1083,7 @@ class core_admin_renderer extends plugin_renderer_base {
                         break;
                     case core_plugin_manager::PLUGIN_STATUS_NODB:
                     case core_plugin_manager::PLUGIN_STATUS_UPTODATE:
-                        $statusclass .= $dependenciesok ? '' : 'badge-warning';
+                        $statusclass .= $dependenciesok ? 'badge-light' : 'badge-warning';
                         break;
                 }
                 $status = html_writer::span(get_string('status_' . $statuscode, 'core_plugin'), $statusclass);
@@ -1092,7 +1093,7 @@ class core_admin_renderer extends plugin_renderer_base {
                         new moodle_url($this->page->url, array('abortinstall' => $plugin->component)),
                         get_string('cancelinstallone', 'core_plugin'),
                         'post',
-                        array('class' => 'actionbutton cancelinstallone')
+                        array('class' => 'actionbutton cancelinstallone d-block mt-1')
                     );
                 }
 
@@ -1101,7 +1102,7 @@ class core_admin_renderer extends plugin_renderer_base {
                         new moodle_url($this->page->url, array('abortupgrade' => $plugin->component)),
                         get_string('cancelupgradeone', 'core_plugin'),
                         'post',
-                        array('class' => 'actionbutton cancelupgradeone')
+                        array('class' => 'actionbutton cancelupgradeone d-block mt-1')
                     );
                 }
 
@@ -1174,7 +1175,7 @@ class core_admin_renderer extends plugin_renderer_base {
             }
         }
 
-        $out .= $this->output->container_start('actions');
+        $out .= $this->output->container_start('actions mb-2');
 
         $installableupdates = $pluginman->filter_installable($pluginman->available_updates());
         if ($installableupdates) {
@@ -1182,7 +1183,7 @@ class core_admin_renderer extends plugin_renderer_base {
                 new moodle_url($this->page->url, array('installupdatex' => 1)),
                 get_string('updateavailableinstallall', 'core_admin', count($installableupdates)),
                 'post',
-                array('class' => 'singlebutton updateavailableinstallall')
+                array('class' => 'singlebutton updateavailableinstallall mr-1')
             );
         }
 
@@ -1191,7 +1192,7 @@ class core_admin_renderer extends plugin_renderer_base {
                 new moodle_url($this->page->url, array('abortinstallx' => 1)),
                 get_string('cancelinstallall', 'core_plugin', count($installabortable)),
                 'post',
-                array('class' => 'singlebutton cancelinstallall')
+                array('class' => 'singlebutton cancelinstallall mr-1')
             );
         }
 
@@ -1200,15 +1201,17 @@ class core_admin_renderer extends plugin_renderer_base {
                 new moodle_url($this->page->url, array('abortupgradex' => 1)),
                 get_string('cancelupgradeall', 'core_plugin', count($upgradeabortable)),
                 'post',
-                array('class' => 'singlebutton cancelupgradeall')
+                array('class' => 'singlebutton cancelupgradeall mr-1')
             );
         }
 
         $out .= html_writer::div(html_writer::link(new moodle_url($this->page->url, array('showallplugins' => 0)),
-            get_string('plugincheckattention', 'core_plugin')).' '.html_writer::span($sumattention, 'badge'));
+            get_string('plugincheckattention', 'core_plugin')).' '.html_writer::span($sumattention, 'badge badge-light'),
+            'btn btn-link mr-1');
 
         $out .= html_writer::div(html_writer::link(new moodle_url($this->page->url, array('showallplugins' => 1)),
-            get_string('plugincheckall', 'core_plugin')).' '.html_writer::span($sumtotal, 'badge'));
+            get_string('plugincheckall', 'core_plugin')).' '.html_writer::span($sumtotal, 'badge badge-light'),
+            'btn btn-link mr-1');
 
         $out .= $this->output->container_end(); // End of .actions container.
         $out .= $this->output->container_end(); // End of #plugins-check-info container.
@@ -1276,12 +1279,13 @@ class core_admin_renderer extends plugin_renderer_base {
             }
         }
 
-        $out  = $this->output->container_start('plugins-check-dependencies');
+        $out  = $this->output->container_start('plugins-check-dependencies mb-4');
 
         if ($unavailable or $unknown) {
             $out .= $this->output->heading(get_string('misdepsunavail', 'core_plugin'));
             if ($unknown) {
-                $out .= $this->output->notification(get_string('misdepsunknownlist', 'core_plugin', implode(', ', $unknown)));
+                $out .= $this->output->render((new \core\output\notification(get_string('misdepsunknownlist', 'core_plugin',
+                    implode(', ', $unknown))))->set_show_closebutton(false));
             }
             if ($unavailable) {
                 $unavailablelist = array();
@@ -1295,18 +1299,18 @@ class core_admin_renderer extends plugin_renderer_base {
                     }
                     $unavailablelist[] = $unavailablelistitem;
                 }
-                $out .= $this->output->notification(get_string('misdepsunavaillist', 'core_plugin',
-                    implode(', ', $unavailablelist)));
+                $out .= $this->output->render((new \core\output\notification(get_string('misdepsunavaillist', 'core_plugin',
+                    implode(', ', $unavailablelist))))->set_show_closebutton(false));
             }
-            $out .= $this->output->container_start('plugins-check-dependencies-actions');
+            $out .= $this->output->container_start('plugins-check-dependencies-actions mb-4');
             $out .= ' '.html_writer::link(new moodle_url('/admin/tool/installaddon/'),
-                get_string('dependencyuploadmissing', 'core_plugin'));
+                get_string('dependencyuploadmissing', 'core_plugin'), array('class' => 'btn btn-secondary'));
             $out .= $this->output->container_end(); // End of .plugins-check-dependencies-actions container.
         }
 
         if ($available) {
             $out .= $this->output->heading(get_string('misdepsavail', 'core_plugin'));
-            $out .= $this->output->container_start('plugins-check-dependencies-actions');
+            $out .= $this->output->container_start('plugins-check-dependencies-actions mb-2');
 
             $installable = $pluginman->filter_installable($available);
             if ($installable) {
@@ -1314,12 +1318,13 @@ class core_admin_renderer extends plugin_renderer_base {
                     new moodle_url($this->page->url, array('installdepx' => 1)),
                     get_string('dependencyinstallmissing', 'core_plugin', count($installable)),
                     'post',
-                    array('class' => 'singlebutton dependencyinstallmissing')
+                    array('class' => 'singlebutton dependencyinstallmissing d-inline-block mr-1')
                 );
             }
 
             $out .= html_writer::div(html_writer::link(new moodle_url('/admin/tool/installaddon/'),
-                get_string('dependencyuploadmissing', 'core_plugin')), 'dependencyuploadmissing');
+                get_string('dependencyuploadmissing', 'core_plugin'), array('class' => 'btn btn-link')),
+                'dependencyuploadmissing d-inline-block mr-1');
 
             $out .= $this->output->container_end(); // End of .plugins-check-dependencies-actions container.
 
@@ -1360,7 +1365,7 @@ class core_admin_renderer extends plugin_renderer_base {
                 if ($CFG->branch == str_replace('.', '', $moodle->release)) {
                     $supportedmoodles[] = html_writer::span($moodle->release, 'badge badge-success');
                 } else {
-                    $supportedmoodles[] = html_writer::span($moodle->release, 'label');
+                    $supportedmoodles[] = html_writer::span($moodle->release, 'badge badge-light');
                 }
             }
 
@@ -1374,7 +1379,7 @@ class core_admin_renderer extends plugin_renderer_base {
                 }
                 $info = html_writer::div(
                     get_string('requiredby', 'core_plugin', implode(', ', $requriedby)),
-                    'requiredby'
+                    'requiredby mb-1'
                 );
             } else {
                 $info = '';
@@ -1385,35 +1390,36 @@ class core_admin_renderer extends plugin_renderer_base {
             $info .= html_writer::div(
                 html_writer::link('https://moodle.org/plugins/view.php?plugin='.$plugin->component,
                     get_string('misdepinfoplugin', 'core_plugin')),
-                'misdepinfoplugin'
+                'misdepinfoplugin d-inline-block mr-3 mb-1'
             );
 
             $info .= html_writer::div(
                 html_writer::link('https://moodle.org/plugins/pluginversion.php?id='.$plugin->version->id,
                     get_string('misdepinfoversion', 'core_plugin')),
-                'misdepinfoversion'
+                'misdepinfoversion d-inline-block mr-3 mb-1'
             );
 
-            $info .= html_writer::div(html_writer::link($plugin->version->downloadurl, get_string('download')), 'misdepdownload');
+            $info .= html_writer::div(html_writer::link($plugin->version->downloadurl, get_string('download')),
+                'misdepdownload d-inline-block mr-3 mb-1');
 
             if ($pluginman->is_remote_plugin_installable($plugin->component, $plugin->version->version, $reason)) {
                 $info .= $this->output->single_button(
                     new moodle_url($this->page->url, array('installdep' => $plugin->component)),
                     get_string('dependencyinstall', 'core_plugin'),
                     'post',
-                    array('class' => 'singlebutton dependencyinstall')
+                    array('class' => 'singlebutton dependencyinstall mr-3 mb-1')
                 );
             } else {
                 $reasonhelp = $this->info_remote_plugin_not_installable($reason);
                 if ($reasonhelp) {
-                    $info .= html_writer::div($reasonhelp, 'reasonhelp dependencyinstall');
+                    $info .= html_writer::div($reasonhelp, 'reasonhelp dependencyinstall d-inline-block mr-3 mb-1');
                 }
             }
 
             $info .= $this->output->container_end(); // End of .actions container.
 
             $table->data[] = array(
-                html_writer::div($plugin->name, 'name').' '.html_writer::div($plugin->component, 'component'),
+                html_writer::div($plugin->name, 'name').' '.html_writer::div($plugin->component, 'component text-muted small'),
                 $plugin->version->release,
                 $plugin->version->version,
                 implode(' ', $supportedmoodles),
@@ -1461,7 +1467,7 @@ class core_admin_renderer extends plugin_renderer_base {
         foreach ($requirements as $reqname => $reqinfo) {
             if ($reqname === 'core') {
                 if ($reqinfo->status == $pluginman::REQUIREMENT_STATUS_OK) {
-                    $class = 'requires-ok';
+                    $class = 'requires-ok text-muted';
                     $label = '';
                 } else {
                     $class = 'requires-failed';
@@ -1490,7 +1496,7 @@ class core_admin_renderer extends plugin_renderer_base {
 
                 if ($reqinfo->status == $pluginman::REQUIREMENT_STATUS_OK) {
                     $label = '';
-                    $class = 'requires-ok';
+                    $class = 'requires-ok text-muted';
 
                 } else if ($reqinfo->status == $pluginman::REQUIREMENT_STATUS_MISSING) {
                     if ($reqinfo->availability == $pluginman::REQUIREMENT_AVAILABLE) {
@@ -1544,13 +1550,14 @@ class core_admin_renderer extends plugin_renderer_base {
             return '';
         }
 
-        $out = html_writer::tag('ul', implode("\n", $requires));
+        $out = html_writer::tag('ul', implode("\n", $requires), array('class' => 'm-0'));
 
         if ($displayuploadlink) {
             $out .= html_writer::div(
                 html_writer::link(
                     new moodle_url('/admin/tool/installaddon/'),
-                    get_string('dependencyuploadmissing', 'core_plugin')
+                    get_string('dependencyuploadmissing', 'core_plugin'),
+                    array('class' => 'btn btn-secondary btn-sm m-1')
                 ),
                 'dependencyuploadmissing'
             );
@@ -1560,7 +1567,8 @@ class core_admin_renderer extends plugin_renderer_base {
             $out .= html_writer::div(
                 html_writer::link(
                     new moodle_url($this->page->url, array('sesskey' => sesskey(), 'fetchupdates' => 1)),
-                    get_string('checkforupdates', 'core_plugin')
+                    get_string('checkforupdates', 'core_plugin'),
+                    array('class' => 'btn btn-secondary btn-sm m-1')
                 ),
                 'checkforupdates'
             );
index c6160d3..1136bd0 100644 (file)
@@ -49,6 +49,8 @@ class api {
     const LOGIN_VIA_EMBEDDED_BROWSER = 3;
     /** @var int seconds an auto-login key will expire. */
     const LOGIN_KEY_TTL = 60;
+    /** @var string URL of the Moodle Apps Portal */
+    const MOODLE_APPS_PORTAL_URL = 'https://apps.moodle.com';
 
     /**
      * Returns a list of Moodle plugins supporting the mobile app.
index 092f7da..ccc6867 100644 (file)
@@ -91,6 +91,7 @@ $string['mobilecssurl'] = 'CSS';
 $string['mobilefeatures'] = 'Mobile features';
 $string['mobilenotificationsdisabledwarning'] = 'Mobile notifications are not enabled. They should be enabled in Notification settings.';
 $string['mobilesettings'] = 'Mobile settings';
+$string['moodleappsportalfeatureswarning'] = 'Please note that some features may be restricted depending on your Moodle app subscription. For details, visit the <a href="{$a}" target="_blank">Moodle Apps Portal</a>.';
 $string['oauth2identityproviders'] = 'OAuth 2 identity providers';
 $string['offlineuse'] = 'Offline use';
 $string['pluginname'] = 'Moodle app tools';
index a9cdbcf..05ed4aa 100644 (file)
@@ -50,6 +50,12 @@ if ($hassiteconfig) {
 
     // Show only mobile settings if the mobile service is enabled.
     if (!empty($CFG->enablemobilewebservice)) {
+        // General notification about limited features due to app restrictions.
+        $notify = new \core\output\notification(
+            get_string('moodleappsportalfeatureswarning', 'tool_mobile', tool_mobile\api::MOODLE_APPS_PORTAL_URL),
+            \core\output\notification::NOTIFY_WARNING);
+        $featuresnotice = $OUTPUT->render($notify);
+
         // Type of login.
         $temp = new admin_settingpage('mobileauthentication', new lang_string('mobileauthentication', 'tool_mobile'));
         $options = array(
@@ -74,6 +80,8 @@ if ($hassiteconfig) {
         // Appearance related settings.
         $temp = new admin_settingpage('mobileappearance', new lang_string('mobileappearance', 'tool_mobile'));
 
+        $temp->add(new admin_setting_heading('tool_mobile/moodleappsportalfeaturesappearance', '', $featuresnotice));
+
         $temp->add(new admin_setting_configtext('mobilecssurl', new lang_string('mobilecssurl', 'tool_mobile'),
                     new lang_string('configmobilecssurl', 'tool_mobile'), '', PARAM_URL));
 
@@ -106,6 +114,8 @@ if ($hassiteconfig) {
         // Features related settings.
         $temp = new admin_settingpage('mobilefeatures', new lang_string('mobilefeatures', 'tool_mobile'));
 
+        $temp->add(new admin_setting_heading('tool_mobile/moodleappsportalfeatures', '', $featuresnotice));
+
         $temp->add(new admin_setting_heading('tool_mobile/logout',
                     new lang_string('logout'), ''));
 
index 11f86c8..991af64 100644 (file)
 
 namespace availability_completion;
 
+use cache;
+use core_availability\info;
+use core_availability\info_module;
+use core_availability\info_section;
+use stdClass;
+
 defined('MOODLE_INTERNAL') || die();
 
 require_once($CFG->libdir . '/completionlib.php');
@@ -36,14 +42,27 @@ require_once($CFG->libdir . '/completionlib.php');
  * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
 class condition extends \core_availability\condition {
+
+    /** @var int previous module cm value used to calculate relative completions */
+    public const OPTION_PREVIOUS = -1;
+
     /** @var int ID of module that this depends on */
     protected $cmid;
 
+    /** @var array IDs of the current module and section */
+    protected $selfids;
+
     /** @var int Expected completion type (one of the COMPLETE_xx constants) */
     protected $expectedcompletion;
 
+    /** @var array Array of previous cmids used to calculate relative completions */
+    protected $modfastprevious = [];
+
+    /** @var array Array of cmids previous to each course section */
+    protected $sectionfastprevious = [];
+
     /** @var array Array of modules used in these conditions for course */
-    protected static $modsusedincondition = array();
+    protected static $modsusedincondition = [];
 
     /**
      * Constructor.
@@ -58,20 +77,27 @@ class condition extends \core_availability\condition {
         } else {
             throw new \coding_exception('Missing or invalid ->cm for completion condition');
         }
-
         // Get expected completion.
         if (isset($structure->e) && in_array($structure->e,
-                array(COMPLETION_COMPLETE, COMPLETION_INCOMPLETE,
-                        COMPLETION_COMPLETE_PASS, COMPLETION_COMPLETE_FAIL))) {
+                [COMPLETION_COMPLETE, COMPLETION_INCOMPLETE,
+                COMPLETION_COMPLETE_PASS, COMPLETION_COMPLETE_FAIL])) {
             $this->expectedcompletion = $structure->e;
         } else {
             throw new \coding_exception('Missing or invalid ->e for completion condition');
         }
     }
 
-    public function save() {
-        return (object)array('type' => 'completion',
-                'cm' => $this->cmid, 'e' => $this->expectedcompletion);
+    /**
+     * Saves tree data back to a structure object.
+     *
+     * @return stdClass Structure object (ready to be made into JSON format)
+     */
+    public function save(): stdClass {
+        return (object) [
+            'type' => 'completion',
+            'cm' => $this->cmid,
+            'e' => $this->expectedcompletion,
+        ];
     }
 
     /**
@@ -84,22 +110,41 @@ class condition extends \core_availability\condition {
      * @param int $expectedcompletion Expected completion value (COMPLETION_xx)
      * @return stdClass Object representing condition
      */
-    public static function get_json($cmid, $expectedcompletion) {
-        return (object)array('type' => 'completion', 'cm' => (int)$cmid,
-                'e' => (int)$expectedcompletion);
+    public static function get_json(int $cmid, int $expectedcompletion): stdClass {
+        return (object) [
+            'type' => 'completion',
+            'cm' => (int)$cmid,
+            'e' => (int)$expectedcompletion,
+        ];
     }
 
-    public function is_available($not, \core_availability\info $info, $grabthelot, $userid) {
+    /**
+     * Determines whether a particular item is currently available
+     * according to this availability condition.
+     *
+     * @see \core_availability\tree_node\update_after_restore
+     *
+     * @param bool $not Set true if we are inverting the condition
+     * @param info $info Item we're checking
+     * @param bool $grabthelot Performance hint: if true, caches information
+     *   required for all course-modules, to make the front page and similar
+     *   pages work more quickly (works only for current user)
+     * @param int $userid User ID to check availability for
+     * @return bool True if available
+     */
+    public function is_available($not, info $info, $grabthelot, $userid): bool {
+        list($selfcmid, $selfsectionid) = $this->get_selfids($info);
+        $cmid = $this->get_cmid($info->get_course(), $selfcmid, $selfsectionid);
         $modinfo = $info->get_modinfo();
         $completion = new \completion_info($modinfo->get_course());
-        if (!array_key_exists($this->cmid, $modinfo->cms)) {
+        if (!array_key_exists($cmid, $modinfo->cms) || $modinfo->cms[$cmid]->deletioninprogress) {
             // If the cmid cannot be found, always return false regardless
             // of the condition or $not state. (Will be displayed in the
             // information message.)
             $allow = false;
         } else {
             // The completion system caches its own data so no caching needed here.
-            $completiondata = $completion->get_data((object)array('id' => $this->cmid),
+            $completiondata = $completion->get_data((object)['id' => $cmid],
                     $grabthelot, $userid, $modinfo);
 
             $allow = true;
@@ -128,6 +173,134 @@ class condition extends \core_availability\condition {
         return $allow;
     }
 
+    /**
+     * Return current item IDs (cmid and sectionid).
+     *
+     * @param info $info
+     * @return int[] with [0] => cmid/null, [1] => sectionid/null
+     */
+    public function get_selfids(info $info): array {
+        if (isset($this->selfids)) {
+            return $this->selfids;
+        }
+        if ($info instanceof info_module) {
+            $cminfo = $info->get_course_module();
+            if (!empty($cminfo->id)) {
+                $this->selfids = [$cminfo->id, null];
+                return $this->selfids;
+            }
+        }
+        if ($info instanceof info_section) {
+            $section = $info->get_section();
+            if (!empty($section->id)) {
+                $this->selfids = [null, $section->id];
+                return $this->selfids;
+            }
+
+        }
+        return [null, null];
+    }
+
+    /**
+     * Get the cmid referenced in the access restriction.
+     *
+     * @param stdClass $course course object
+     * @param int|null $selfcmid current course-module ID or null
+     * @param int|null $selfsectionid current course-section ID or null
+     * @return int|null cmid or null if no referenced cm is found
+     */
+    public function get_cmid(stdClass $course, ?int $selfcmid, ?int $selfsectionid): ?int {
+        if ($this->cmid > 0) {
+            return $this->cmid;
+        }
+        // If it's a relative completion, load fast browsing.
+        if ($this->cmid == self::OPTION_PREVIOUS) {
+            $prevcmid = $this->get_previous_cmid($course, $selfcmid, $selfsectionid);
+            if ($prevcmid) {
+                return $prevcmid;
+            }
+        }
+        return null;
+    }
+
+    /**
+     * Return the previous CM ID of an specific course-module or course-section.
+     *
+     * @param stdClass $course course object
+     * @param int|null $selfcmid course-module ID or null
+     * @param int|null $selfsectionid course-section ID or null
+     * @return int|null
+     */
+    private function get_previous_cmid(stdClass $course, ?int $selfcmid, ?int $selfsectionid): ?int {
+        $this->load_course_structure($course);
+        if (isset($this->modfastprevious[$selfcmid])) {
+            return $this->modfastprevious[$selfcmid];
+        }
+        if (isset($this->sectionfastprevious[$selfsectionid])) {
+            return $this->sectionfastprevious[$selfsectionid];
+        }
+        return null;
+    }
+
+    /**
+     * Loads static information about a course elements previous activities.
+     *
+     * Populates two variables:
+     *   - $this->sectionprevious[] course-module previous to a cmid
+     *   - $this->sectionfastprevious[] course-section previous to a cmid
+     *
+     * @param stdClass $course course object
+     */
+    private function load_course_structure(stdClass $course): void {
+        // If already loaded we don't need to do anything.
+        if (empty($this->modfastprevious)) {
+            $previouscache = cache::make('availability_completion', 'previous_cache');
+            $this->modfastprevious = $previouscache->get("mod_{$course->id}");
+            $this->sectionfastprevious = $previouscache->get("sec_{$course->id}");
+        }
+
+        if (!empty($this->modfastprevious)) {
+            return;
+        }
+
+        if (empty($this->modfastprevious)) {
+            $this->modfastprevious = [];
+            $sectionprevious = [];
+
+            $modinfo = get_fast_modinfo($course);
+            $lastcmid = 0;
+            foreach ($modinfo->cms as $othercm) {
+                if ($othercm->deletioninprogress) {
+                    continue;
+                }
+                // Save first cm of every section.
+                if (!isset($sectionprevious[$othercm->section])) {
+                    $sectionprevious[$othercm->section] = $lastcmid;
+                }
+                // Load previous to all cms with completion.
+                if ($othercm->completion == COMPLETION_TRACKING_NONE) {
+                    continue;
+                }
+                if ($lastcmid) {
+                    $this->modfastprevious[$othercm->id] = $lastcmid;
+                }
+                $lastcmid = $othercm->id;
+            }
+            // Fill empty sections index.
+            $isections = array_reverse($modinfo->get_section_info_all());
+            foreach ($isections as $section) {
+                if (isset($sectionprevious[$section->id])) {
+                    $lastcmid = $sectionprevious[$section->id];
+                } else {
+                    $sectionprevious[$section->id] = $lastcmid;
+                }
+            }
+            $this->sectionfastprevious = $sectionprevious;
+            $previouscache->set("mod_{$course->id}", $this->modfastprevious);
+            $previouscache->set("sec_{$course->id}", $this->sectionfastprevious);
+        }
+    }
+
     /**
      * Returns a more readable keyword corresponding to a completion state.
      *
@@ -136,7 +309,7 @@ class condition extends \core_availability\condition {
      * @param int $completionstate COMPLETION_xx constant
      * @return string Readable keyword
      */
-    protected static function get_lang_string_keyword($completionstate) {
+    protected static function get_lang_string_keyword(int $completionstate): string {
         switch($completionstate) {
             case COMPLETION_INCOMPLETE:
                 return 'incomplete';
@@ -151,38 +324,69 @@ class condition extends \core_availability\condition {
         }
     }
 
-    public function get_description($full, $not, \core_availability\info $info) {
-        // Get name for module.
-        $modinfo = $info->get_modinfo();
-        if (!array_key_exists($this->cmid, $modinfo->cms)) {
-            $modname = get_string('missing', 'availability_completion');
+    /**
+     * Obtains a string describing this restriction (whether or not
+     * it actually applies).
+     *
+     * @param bool $full Set true if this is the 'full information' view
+     * @param bool $not Set true if we are inverting the condition
+     * @param info $info Item we're checking
+     * @return string Information string (for admin) about all restrictions on
+     *   this item
+     */
+    public function get_description($full, $not, info $info): string {
+        global $USER;
+        $str = 'requires_';
+        $course = $info->get_course();
+        list($selfcmid, $selfsectionid) = $this->get_selfids($info);
+        $modname = '';
+        // On ajax duplicate get_fast_modinfo is called before $PAGE->set_context
+        // so we cannot use $PAGE->user_is_editing().
+        $coursecontext = \context_course::instance($course->id);
+        $editing = !empty($USER->editing) && has_capability('moodle/course:manageactivities', $coursecontext);
+        if ($this->cmid == self::OPTION_PREVIOUS && $editing) {
+            // Previous activity name could be inconsistent when editing due to partial page loadings.
+            $str .= 'previous_';
         } else {
-            $modname = '<AVAILABILITY_CMNAME_' . $modinfo->cms[$this->cmid]->id . '/>';
+            // Get name for module.
+            $cmid = $this->get_cmid($course, $selfcmid, $selfsectionid);
+            $modinfo = $info->get_modinfo();
+            if (!array_key_exists($cmid, $modinfo->cms) || $modinfo->cms[$cmid]->deletioninprogress) {
+                $modname = get_string('missing', 'availability_completion');
+            } else {
+                $modname = '<AVAILABILITY_CMNAME_' . $modinfo->cms[$cmid]->id . '/>';
+            }
         }
 
-        // Work out which lang string to use.
+        // Work out which lang string to use depending on required completion status.
         if ($not) {
             // Convert NOT strings to use the equivalent where possible.
             switch ($this->expectedcompletion) {
                 case COMPLETION_INCOMPLETE:
-                    $str = 'requires_' . self::get_lang_string_keyword(COMPLETION_COMPLETE);
+                    $str .= self::get_lang_string_keyword(COMPLETION_COMPLETE);
                     break;
                 case COMPLETION_COMPLETE:
-                    $str = 'requires_' . self::get_lang_string_keyword(COMPLETION_INCOMPLETE);
+                    $str .= self::get_lang_string_keyword(COMPLETION_INCOMPLETE);
                     break;
                 default:
                     // The other two cases do not have direct opposites.
-                    $str = 'requires_not_' . self::get_lang_string_keyword($this->expectedcompletion);
+                    $str .= 'not_' . self::get_lang_string_keyword($this->expectedcompletion);
                     break;
             }
         } else {
-            $str = 'requires_' . self::get_lang_string_keyword($this->expectedcompletion);
+            $str .= self::get_lang_string_keyword($this->expectedcompletion);
         }
 
         return get_string($str, 'availability_completion', $modname);
     }
 
-    protected function get_debug_string() {
+    /**
+     * Obtains a representation of the options of this condition as a string,
+     * for debugging.
+     *
+     * @return string Text representation of parameters
+     */
+    protected function get_debug_string(): string {
         switch ($this->expectedcompletion) {
             case COMPLETION_COMPLETE :
                 $type = 'COMPLETE';
@@ -199,18 +403,38 @@ class condition extends \core_availability\condition {
             default:
                 throw new \coding_exception('Unexpected expected completion');
         }
-        return 'cm' . $this->cmid . ' ' . $type;
+        $cm = $this->cmid;
+        if ($this->cmid == self::OPTION_PREVIOUS) {
+            $cm = 'opprevious';
+        }
+        return 'cm' . $cm . ' ' . $type;
     }
 
-    public function update_after_restore($restoreid, $courseid, \base_logger $logger, $name) {
+    /**
+     * Updates this node after restore, returning true if anything changed.
+     *
+     * @see \core_availability\tree_node\update_after_restore
+     *
+     * @param string $restoreid Restore ID
+     * @param int $courseid ID of target course
+     * @param \base_logger $logger Logger for any warnings
+     * @param string $name Name of this item (for use in warning messages)
+     * @return bool True if there was any change
+     */
+    public function update_after_restore($restoreid, $courseid, \base_logger $logger, $name): bool {
         global $DB;
+        $res = false;
+        // If we depend on the previous activity, no translation is needed.
+        if ($this->cmid == self::OPTION_PREVIOUS) {
+            return $res;
+        }
         $rec = \restore_dbops::get_backup_ids_record($restoreid, 'course_module', $this->cmid);
         if (!$rec || !$rec->newitemid) {
             // If we are on the same course (e.g. duplicate) then we can just
             // use the existing one.
             if ($DB->record_exists('course_modules',
-                    array('id' => $this->cmid, 'course' => $courseid))) {
-                return false;
+                    ['id' => $this->cmid, 'course' => $courseid])) {
+                return $res;
             }
             // Otherwise it's a warning.
             $this->cmid = 0;
@@ -231,13 +455,13 @@ class condition extends \core_availability\condition {
      * @param int $cmid Course-module id
      * @return bool True if this is used in a condition, false otherwise
      */
-    public static function completion_value_used($course, $cmid) {
+    public static function completion_value_used($course, $cmid): bool {
         // Have we already worked out a list of required completion values
         // for this course? If so just use that.
         if (!array_key_exists($course->id, self::$modsusedincondition)) {
             // We don't have data for this course, build it.
             $modinfo = get_fast_modinfo($course);
-            self::$modsusedincondition[$course->id] = array();
+            self::$modsusedincondition[$course->id] = [];
 
             // Activities.
             foreach ($modinfo->cms as $othercm) {
@@ -247,7 +471,10 @@ class condition extends \core_availability\condition {
                 $ci = new \core_availability\info_module($othercm);
                 $tree = $ci->get_availability_tree();
                 foreach ($tree->get_all_children('availability_completion\condition') as $cond) {
-                    self::$modsusedincondition[$course->id][$cond->cmid] = true;
+                    $condcmid = $cond->get_cmid($course, $othercm->id, null);
+                    if (!empty($condcmid)) {
+                        self::$modsusedincondition[$course->id][$condcmid] = true;
+                    }
                 }
             }
 
@@ -259,7 +486,10 @@ class condition extends \core_availability\condition {
                 $ci = new \core_availability\info_section($section);
                 $tree = $ci->get_availability_tree();
                 foreach ($tree->get_all_children('availability_completion\condition') as $cond) {
-                    self::$modsusedincondition[$course->id][$cond->cmid] = true;
+                    $condcmid = $cond->get_cmid($course, null, $section->id);
+                    if (!empty($condcmid)) {
+                        self::$modsusedincondition[$course->id][$condcmid] = true;
+                    }
                 }
             }
         }
@@ -270,7 +500,7 @@ class condition extends \core_availability\condition {
      * Wipes the static cache of modules used in a condition (for unit testing).
      */
     public static function wipe_static_cache() {
-        self::$modsusedincondition = array();
+        self::$modsusedincondition = [];
     }
 
     public function update_dependency_id($table, $oldid, $newid) {
index 7427328..c646685 100644 (file)
@@ -37,7 +37,7 @@ class frontend extends \core_availability\frontend {
     /**
      * @var array Cached init parameters
      */
-    protected $cacheparams = array();
+    protected $cacheparams = [];
 
     /**
      * @var string IDs of course, cm, and section for cache (if any)
@@ -45,8 +45,8 @@ class frontend extends \core_availability\frontend {
     protected $cachekey = '';
 
     protected function get_javascript_strings() {
-        return array('option_complete', 'option_fail', 'option_incomplete', 'option_pass',
-                'label_cm', 'label_completion');
+        return ['option_complete', 'option_fail', 'option_incomplete', 'option_pass',
+                        'label_cm', 'label_completion'];
     }
 
     protected function get_javascript_init_params($course, \cm_info $cm = null,
@@ -59,20 +59,29 @@ class frontend extends \core_availability\frontend {
             // Get list of activities on course which have completion values,
             // to fill the dropdown.
             $context = \context_course::instance($course->id);
-            $cms = array();
+            $cms = [];
             $modinfo = get_fast_modinfo($course);
+            $previouscm = false;
             foreach ($modinfo->cms as $id => $othercm) {
                 // Add each course-module if it has completion turned on and is not
                 // the one currently being edited.
                 if ($othercm->completion && (empty($cm) || $cm->id != $id) && !$othercm->deletioninprogress) {
-                    $cms[] = (object)array('id' => $id,
-                        'name' => format_string($othercm->name, true, array('context' => $context)),
-                        'completiongradeitemnumber' => $othercm->completiongradeitemnumber);
+                    $cms[] = (object)['id' => $id,
+                        'name' => format_string($othercm->name, true, ['context' => $context]),
+                        'completiongradeitemnumber' => $othercm->completiongradeitemnumber];
+                }
+                if (count($cms) && (empty($cm) || $cm->id == $id)) {
+                    $previouscm = true;
                 }
             }
-
+            if ($previouscm) {
+                $previous = (object)['id' => \availability_completion\condition::OPTION_PREVIOUS,
+                        'name' => get_string('option_previous', 'availability_completion'),
+                        'completiongradeitemnumber' => \availability_completion\condition::OPTION_PREVIOUS];
+                array_unshift($cms, $previous);
+            }
             $this->cachekey = $cachekey;
-            $this->cacheinitparams = array($cms);
+            $this->cacheinitparams = [$cms];
         }
         return $this->cacheinitparams;
     }
diff --git a/availability/condition/completion/db/caches.php b/availability/condition/completion/db/caches.php
new file mode 100644 (file)
index 0000000..e365533
--- /dev/null
@@ -0,0 +1,35 @@
+<?php
+// This file is part of Moodle - https://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/>.
+
+/**
+ * Defined caches used internally by the plugin.
+ *
+ * @package     availability_completion
+ * @category    cache
+ * @copyright   2020 Ferran Recio <ferran@moodle.com>
+ * @license     http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+$definitions = [
+    'previous_cache' => [
+        'mode' => cache_store::MODE_REQUEST,
+        'simplekeys' => true,
+        'simpledata' => true,
+        'staticacceleration' => true
+    ],
+];
index f3a8693..4d406da 100644 (file)
@@ -22,6 +22,7 @@
  * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
 
+$string['cachedef_previous_cache'] = 'Previous activity dependency information';
 $string['description'] = 'Require students to complete (or not complete) another activity.';
 $string['error_selectcmid'] = 'You must select an activity for the completion condition.';
 $string['error_selectcmidpassfail'] = 'You must select an activity with "Require grade" completion condition set.';
@@ -32,6 +33,7 @@ $string['option_complete'] = 'must be marked complete';
 $string['option_fail'] = 'must be complete with fail grade';
 $string['option_incomplete'] = 'must not be marked complete';
 $string['option_pass'] = 'must be complete with pass grade';
+$string['option_previous'] = 'Previous activity with completion';
 $string['pluginname'] = 'Restriction by activity completion';
 $string['requires_incomplete'] = 'The activity <strong>{$a}</strong> is incomplete';
 $string['requires_complete'] = 'The activity <strong>{$a}</strong> is marked complete';
@@ -39,5 +41,11 @@ $string['requires_complete_pass'] = 'The activity <strong>{$a}</strong> is compl
 $string['requires_complete_fail'] = 'The activity <strong>{$a}</strong> is complete and failed';
 $string['requires_not_complete_pass'] = 'The activity <strong>{$a}</strong> is not complete and passed';
 $string['requires_not_complete_fail'] = 'The activity <strong>{$a}</strong> is not complete and failed';
+$string['requires_previous_incomplete'] = 'The <strong>previous activity with completion</strong> is incomplete';
+$string['requires_previous_complete'] = 'The <strong>previous activity with completion</strong> is marked complete';
+$string['requires_previous_complete_pass'] = 'The <strong>previous activity with completion</strong> is complete and passed';
+$string['requires_previous_complete_fail'] = 'The <strong>previous activity with completion</strong> is complete and failed';
+$string['requires_previous_not_complete_pass'] = 'The <strong>previous activity with completion</strong> is not complete and passed';
+$string['requires_previous_not_complete_fail'] = 'The <strong>previous activity with completion</strong> is not complete and failed';
 $string['title'] = 'Activity completion';
 $string['privacy:metadata'] = 'The Restriction by activity completion plugin does not store any personal data.';
diff --git a/availability/condition/completion/tests/behat/availability_completion_previous.feature b/availability/condition/completion/tests/behat/availability_completion_previous.feature
new file mode 100644 (file)
index 0000000..ae21266
--- /dev/null
@@ -0,0 +1,200 @@
+@availability @availability_completion
+Feature: Confirm that availability_completion works with previous activity setting
+  In order to control student access to activities
+  As a teacher
+  I need to set completion conditions which prevent student access
+
+  Background:
+    Given the following "courses" exist:
+      | fullname | shortname | format | enablecompletion | numsections |
+      | Course 1 | C1        | topics | 1                | 5           |
+    And the following "users" exist:
+      | username |
+      | teacher1 |
+    And the following "course enrolments" exist:
+      | user     | course | role           |
+      | teacher1 | C1     | editingteacher |
+    Given the following "activities" exist:
+      | activity | name           | intro              | course | idnumber | groupmode | completion | section |
+      | page     | Page1          | Page 1 description | C1     | page1    | 1         | 1          | 1       |
+      | page     | Page Ignored 1 | Page Ignored       | C1     | pagei1   | 1         | 0          | 1       |
+      | page     | Page2          | Page 2 description | C1     | page2    | 1         | 1          | 3       |
+      | page     | Page3          | Page 3 description | C1     | page3    | 1         | 1          | 4       |
+
+  @javascript
+  Scenario: Test condition with previous activity on an activity
+    Given I log in as "teacher1"
+    And I am on "Course 1" course homepage with editing mode on
+
+    # Set Page3 restriction to Previous Activity with completion.
+    When I open "Page3" actions menu
+    And I click on "Edit settings" "link" in the "Page3" activity
+    And I expand all fieldsets
+    And I click on "Add restriction..." "button"
+    And I click on "Activity completion" "button" in the "Add restriction..." "dialogue"
+    And I click on "Displayed greyed-out if user does not meet this condition • Click to hide" "link"
+    And I set the field "Activity or resource" to "Previous activity with completion"
+    And I press "Save and return to course"
+    Then I should see "Not available unless: The previous activity with completion" in the "region-main" "region"
+
+    When I turn editing mode off
+    Then I should see "Not available unless: The activity Page2 is marked complete" in the "region-main" "region"
+
+    # Remove Page 2 and check Page3 depends now on Page1.
+    When I turn editing mode on
+    And I delete "Page2" activity
+    And I turn editing mode off
+    Then I should see "Not available unless: The activity Page1 is marked complete" in the "region-main" "region"
+
+  @javascript
+  Scenario: Test previous activity availability when duplicate an activity
+    Given I log in as "teacher1"
+    And I am on "Course 1" course homepage with editing mode on
+
+    # Set Page3 restriction to Previous Activity with completion.
+    When I open "Page3" actions menu
+    And I click on "Edit settings" "link" in the "Page3" activity
+    And I expand all fieldsets
+    And I click on "Add restriction..." "button"
+    And I click on "Activity completion" "button" in the "Add restriction..." "dialogue"
+    And I click on "Displayed greyed-out if user does not meet this condition • Click to hide" "link"
+    And I set the field "Activity or resource" to "Previous activity with completion"
+    And I press "Save and return to course"
+    Then I should see "Not available unless: The previous activity with completion" in the "region-main" "region"
+
+    When I turn editing mode off
+    Then I should see "Not available unless: The activity Page2 is marked complete" in the "region-main" "region"
+
+    # Duplicate Page3.
+    When I turn editing mode on
+    And I duplicate "Page3" activity
+    And I turn editing mode off
+    Then I should see "Not available unless: The activity Page3 is marked complete" in the "region-main" "region"
+
+  @javascript
+  Scenario: Test previous activity availability when modify completion tacking
+    Given I log in as "teacher1"
+    And I am on "Course 1" course homepage with editing mode on
+
+    # Set Page3 restriction to Previous Activity with completion.
+    When I open "Page3" actions menu
+    And I click on "Edit settings" "link" in the "Page3" activity
+    And I expand all fieldsets
+    And I click on "Add restriction..." "button"
+    And I click on "Activity completion" "button" in the "Add restriction..." "dialogue"
+    And I click on "Displayed greyed-out if user does not meet this condition • Click to hide" "link"
+    And I set the field "Activity or resource" to "Previous activity with completion"
+    And I press "Save and return to course"
+    Then I should see "Not available unless: The previous activity with completion" in the "region-main" "region"
+
+    When I turn editing mode off
+    Then I should see "Not available unless: The activity Page2 is marked complete" in the "region-main" "region"
+
+    # Test if I disable completion tracking on Page2 section 5 depends on Page2.
+    When I turn editing mode on
+    When I open "Page2" actions menu
+    And I click on "Edit settings" "link" in the "Page2" activity
+    And I set the following fields to these values:
+      | Completion tracking | Do not indicate activity completion |
+    And I press "Save and return to course"
+    When I turn editing mode off
+    Then I should see "Not available unless: The activity Page1 is marked complete" in the "region-main" "region"
+
+  @javascript
+  Scenario: Test condition with previous activity on a section
+    Given I log in as "teacher1"
+    And I am on "Course 1" course homepage with editing mode on
+
+    # Set section 4 restriction to Previous Activity with completion.
+    When I edit the section "4"
+    And I expand all fieldsets
+    And I click on "Add restriction..." "button"
+    And I click on "Activity completion" "button" in the "Add restriction..." "dialogue"
+    And I click on "Displayed greyed-out if user does not meet this condition • Click to hide" "link"
+    And I set the field "Activity or resource" to "Previous activity with completion"
+    And I press "Save changes"
+    Then I should see "Not available unless: The previous activity with completion" in the "region-main" "region"
+
+    When I turn editing mode off
+    Then I should see "Not available unless: The activity Page2 is marked complete" in the "region-main" "region"
+
+    # Remove Page 2 and check Section 4 depends now on Page1.
+    When I turn editing mode on
+    And I delete "Page2" activity
+    And I turn editing mode off
+    Then I should see "Not available unless: The activity Page1 is marked complete" in the "region-main" "region"
+
+  @javascript
+  Scenario: Test condition with previous activity on the first activity of the course
+    Given I log in as "teacher1"
+    And I am on "Course 1" course homepage with editing mode on
+
+    # Try to set Page1 restriction to Previous Activity with completion.
+    When I open "Page1" actions menu
+    And I click on "Edit settings" "link" in the "Page1" activity
+    And I expand all fieldsets
+    And I click on "Add restriction..." "button"
+    And I click on "Activity completion" "button" in the "Add restriction..." "dialogue"
+    And I click on "Displayed greyed-out if user does not meet this condition • Click to hide" "link"
+    Then the "Activity or resource" select box should not contain "Previous activity with completion"
+
+    # Set Page2 restriction to Previous Activity with completion and delete Page1.
+    When I am on "Course 1" course homepage
+    When I open "Page2" actions menu
+    And I click on "Edit settings" "link" in the "Page2" activity
+    And I expand all fieldsets
+    And I click on "Add restriction..." "button"
+    And I click on "Activity completion" "button" in the "Add restriction..." "dialogue"
+    And I click on "Displayed greyed-out if user does not meet this condition • Click to hide" "link"
+    And I set the field "Activity or resource" to "Previous activity with completion"
+    And I press "Save and return to course"
+    Then I should see "Not available unless: The previous activity with completion" in the "region-main" "region"
+
+    # Delete Page 1 and check than Page2 now depends on a missing activity (no previous activity found).
+    When I am on "Course 1" course homepage
+    And I delete "Page1" activity
+    And I turn editing mode off
+    Then I should see "Not available unless: The activity (Missing activity)" in the "region-main" "region"
+
+  @javascript
+  Scenario: Test previous activities on empty sections
+    Given I log in as "teacher1"
+    And I am on "Course 1" course homepage with editing mode on
+
+    # Set section 2 restriction to Previous Activity with completion.
+    When I edit the section "2"
+    And I expand all fieldsets
+    And I click on "Add restriction..." "button"
+    And I click on "Activity completion" "button" in the "Add restriction..." "dialogue"
+    And I click on "Displayed greyed-out if user does not meet this condition • Click to hide" "link"
+    And I set the field "Activity or resource" to "Previous activity with completion"
+    And I press "Save changes"
+    Then I should see "Not available unless: The previous activity with completion" in the "region-main" "region"
+
+    When I turn editing mode off
+    Then I should see "Not available unless: The activity Page1 is marked complete" in the "region-main" "region"
+
+    # Set section 5 restriction to Previous Activity with completion.
+    When I turn editing mode on
+    And I edit the section "5"
+    And I expand all fieldsets
+    And I click on "Add restriction..." "button"
+    And I click on "Activity completion" "button" in the "Add restriction..." "dialogue"
+    And I click on "Displayed greyed-out if user does not meet this condition • Click to hide" "link"
+    And I set the field "Activity or resource" to "Previous activity with completion"
+    And I press "Save changes"
+    Then I should see "Not available unless: The previous activity with completion" in the "region-main" "region"
+
+    When I turn editing mode off
+    Then I should see "Not available unless: The activity Page3 is marked complete" in the "region-main" "region"
+
+    # Test if I disable completion tracking on Page3 section 5 depends on Page2.
+    When I turn editing mode on
+    And I open "Page3" actions menu
+    And I click on "Edit settings" "link" in the "Page3" activity
+    And I set the following fields to these values:
+      | Completion tracking | Do not indicate activity completion |
+    And I press "Save and return to course"
+
+    When I turn editing mode off
+    Then I should see "Not available unless: The activity Page2 is marked complete" in the "region-main" "region"
index c64be08..b1a0040 100644 (file)
@@ -37,13 +37,23 @@ require_once($CFG->libdir . '/completionlib.php');
  * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
 class availability_completion_condition_testcase extends advanced_testcase {
+
     /**
-     * Load required classes.
+     * Setup to ensure that fixtures are loaded.
      */
-    public function setUp() {
-        // Load the mock info class so that it can be used.
+    public static function setupBeforeClass(): void {
         global $CFG;
+        // Load the mock info class so that it can be used.
         require_once($CFG->dirroot . '/availability/tests/fixtures/mock_info.php');
+        require_once($CFG->dirroot . '/availability/tests/fixtures/mock_info_module.php');
+        require_once($CFG->dirroot . '/availability/tests/fixtures/mock_info_section.php');
+    }
+
+    /**
+     * Load required classes.
+     */
+    public function setUp() {
+        availability_completion\condition::wipe_static_cache();
     }
 
     /**
@@ -59,17 +69,27 @@ class availability_completion_condition_testcase extends advanced_testcase {
         $CFG->enablecompletion = true;
         $CFG->enableavailability = true;
         $generator = $this->getDataGenerator();
-        $course = $generator->create_course(array('enablecompletion' => 1));
+        $course = $generator->create_course(['enablecompletion' => 1]);
         $page = $generator->get_plugin_generator('mod_page')->create_instance(
-                array('course' => $course->id, 'completion' => COMPLETION_TRACKING_MANUAL));
+                ['course' => $course->id, 'completion' => COMPLETION_TRACKING_MANUAL]);
+        $selfpage = $generator->get_plugin_generator('mod_page')->create_instance(
+                ['course' => $course->id, 'completion' => COMPLETION_TRACKING_MANUAL]);
 
         $modinfo = get_fast_modinfo($course);
         $cm = $modinfo->get_cm($page->cmid);
         $info = new \core_availability\mock_info($course, $USER->id);
 
-        $structure = (object)array('op' => '|', 'show' => true, 'c' => array(
-                (object)array('type' => 'completion', 'cm' => (int)$cm->id,
-                'e' => COMPLETION_COMPLETE)));
+        $structure = (object)[
+            'op' => '|',
+            'show' => true,
+            'c' => [
+                (object)[
+                    'type' => 'completion',
+                    'cm' => (int)$cm->id,
+                    'e' => COMPLETION_COMPLETE
+                ]
+            ]
+        ];
         $tree = new \core_availability\tree($structure);
 
         // Initial check (user has not completed activity).
@@ -142,13 +162,19 @@ class availability_completion_condition_testcase extends advanced_testcase {
         $structure->e = COMPLETION_INCOMPLETE;
         $cond = new condition($structure);
         $this->assertEquals('{completion:cm42 INCOMPLETE}', (string)$cond);
+
+        // Successful contruct with previous activity.
+        $structure->cm = condition::OPTION_PREVIOUS;
+        $cond = new condition($structure);
+        $this->assertEquals('{completion:cmopprevious INCOMPLETE}', (string)$cond);
+
     }
 
     /**
      * Tests the save() function.
      */
     public function test_save() {
-        $structure = (object)array('cm' => 42, 'e' => COMPLETION_COMPLETE);
+        $structure = (object)['cm' => 42, 'e' => COMPLETION_COMPLETE];
         $cond = new condition($structure);
         $structure->type = 'completion';
         $this->assertEquals($structure, $cond->save());
@@ -166,24 +192,24 @@ class availability_completion_condition_testcase extends advanced_testcase {
         $CFG->enablecompletion = true;
         $CFG->enableavailability = true;
         $generator = $this->getDataGenerator();
-        $course = $generator->create_course(array('enablecompletion' => 1));
+        $course = $generator->create_course(['enablecompletion' => 1]);
         $user = $generator->create_user();
         $generator->enrol_user($user->id, $course->id);
         $this->setUser($user);
 
         // Create a Page with manual completion for basic checks.
         $page = $generator->get_plugin_generator('mod_page')->create_instance(
-                array('course' => $course->id, 'name' => 'Page!',
-                'completion' => COMPLETION_TRACKING_MANUAL));
+                ['course' => $course->id, 'name' => 'Page!',
+                'completion' => COMPLETION_TRACKING_MANUAL]);
 
         // Create an assignment - we need to have something that can be graded
         // so as to test the PASS/FAIL states. Set it up to be completed based
         // on its grade item.
-        $assignrow = $this->getDataGenerator()->create_module('assign', array(
-                'course' => $course->id, 'name' => 'Assign!',
-                'completion' => COMPLETION_TRACKING_AUTOMATIC));
+        $assignrow = $this->getDataGenerator()->create_module('assign', [
+                        'course' => $course->id, 'name' => 'Assign!',
+                        'completion' => COMPLETION_TRACKING_AUTOMATIC]);
         $DB->set_field('course_modules', 'completiongradeitemnumber', 0,
-                array('id' => $assignrow->cmid));
+                ['id' => $assignrow->cmid]);
         $assign = new assign(context_module::instance($assignrow->cmid), false, false);
 
         // Get basic details.
@@ -193,8 +219,9 @@ class availability_completion_condition_testcase extends advanced_testcase {
         $info = new \core_availability\mock_info($course, $user->id);
 
         // COMPLETE state (false), positive and NOT.
-        $cond = new condition((object)array(
-                'cm' => (int)$pagecm->id, 'e' => COMPLETION_COMPLETE));
+        $cond = new condition((object)[
+            'cm' => (int)$pagecm->id, 'e' => COMPLETION_COMPLETE
+        ]);
         $this->assertFalse($cond->is_available(false, $info, true, $user->id));
         $information = $cond->get_description(false, false, $info);
         $information = \core_availability\info::format_info($information, $course);
@@ -202,8 +229,9 @@ class availability_completion_condition_testcase extends advanced_testcase {
         $this->assertTrue($cond->is_available(true, $info, true, $user->id));
 
         // INCOMPLETE state (true).
-        $cond = new condition((object)array(
-                'cm' => (int)$pagecm->id, 'e' => COMPLETION_INCOMPLETE));
+        $cond = new condition((object)[
+            'cm' => (int)$pagecm->id, 'e' => COMPLETION_INCOMPLETE
+        ]);
         $this->assertTrue($cond->is_available(false, $info, true, $user->id));
         $this->assertFalse($cond->is_available(true, $info, true, $user->id));
         $information = $cond->get_description(false, true, $info);
@@ -215,8 +243,9 @@ class availability_completion_condition_testcase extends advanced_testcase {
         $completion->update_state($pagecm, COMPLETION_COMPLETE);
 
         // COMPLETE state (true).
-        $cond = new condition((object)array(
-                'cm' => (int)$pagecm->id, 'e' => COMPLETION_COMPLETE));
+        $cond = new condition((object)[
+            'cm' => (int)$pagecm->id, 'e' => COMPLETION_COMPLETE
+        ]);
         $this->assertTrue($cond->is_available(false, $info, true, $user->id));
         $this->assertFalse($cond->is_available(true, $info, true, $user->id));
         $information = $cond->get_description(false, true, $info);
@@ -224,8 +253,9 @@ class availability_completion_condition_testcase extends advanced_testcase {
         $this->assertRegExp('~Page!.*is incomplete~', $information);
 
         // INCOMPLETE state (false).
-        $cond = new condition((object)array(
-                'cm' => (int)$pagecm->id, 'e' => COMPLETION_INCOMPLETE));
+        $cond = new condition((object)[
+            'cm' => (int)$pagecm->id, 'e' => COMPLETION_INCOMPLETE
+        ]);
         $this->assertFalse($cond->is_available(false, $info, true, $user->id));
         $information = $cond->get_description(false, false, $info);
         $information = \core_availability\info::format_info($information, $course);
@@ -235,32 +265,36 @@ class availability_completion_condition_testcase extends advanced_testcase {
 
         // We are going to need the grade item so that we can get pass/fails.
         $gradeitem = $assign->get_grade_item();
-        grade_object::set_properties($gradeitem, array('gradepass' => 50.0));
+        grade_object::set_properties($gradeitem, ['gradepass' => 50.0]);
         $gradeitem->update();
 
         // With no grade, it should return true for INCOMPLETE and false for
         // the other three.
-        $cond = new condition((object)array(
-                'cm' => (int)$assigncm->id, 'e' => COMPLETION_INCOMPLETE));
+        $cond = new condition((object)[
+            'cm' => (int)$assigncm->id, 'e' => COMPLETION_INCOMPLETE
+        ]);
         $this->assertTrue($cond->is_available(false, $info, true, $user->id));
         $this->assertFalse($cond->is_available(true, $info, true, $user->id));
 
-        $cond = new condition((object)array(
-                'cm' => (int)$assigncm->id, 'e' => COMPLETION_COMPLETE));
+        $cond = new condition((object)[
+            'cm' => (int)$assigncm->id, 'e' => COMPLETION_COMPLETE
+        ]);
         $this->assertFalse($cond->is_available(false, $info, true, $user->id));
         $this->assertTrue($cond->is_available(true, $info, true, $user->id));
 
         // Check $information for COMPLETE_PASS and _FAIL as we haven't yet.
-        $cond = new condition((object)array(
-                'cm' => (int)$assigncm->id, 'e' => COMPLETION_COMPLETE_PASS));
+        $cond = new condition((object)[
+            'cm' => (int)$assigncm->id, 'e' => COMPLETION_COMPLETE_PASS
+        ]);
         $this->assertFalse($cond->is_available(false, $info, true, $user->id));
         $information = $cond->get_description(false, false, $info);
         $information = \core_availability\info::format_info($information, $course);
         $this->assertRegExp('~Assign!.*is complete and passed~', $information);
         $this->assertTrue($cond->is_available(true, $info, true, $user->id));
 
-        $cond = new condition((object)array(
-                'cm' => (int)$assigncm->id, 'e' => COMPLETION_COMPLETE_FAIL));
+        $cond = new condition((object)[
+            'cm' => (int)$assigncm->id, 'e' => COMPLETION_COMPLETE_FAIL
+        ]);
         $this->assertFalse($cond->is_available(false, $info, true, $user->id));
         $information = $cond->get_description(false, false, $info);
         $information = \core_availability\info::format_info($information, $course);
@@ -270,26 +304,30 @@ class availability_completion_condition_testcase extends advanced_testcase {
         // Change the grade to be complete and failed.
         self::set_grade($assignrow, $user->id, 40);
 
-        $cond = new condition((object)array(
-                'cm' => (int)$assigncm->id, 'e' => COMPLETION_INCOMPLETE));
+        $cond = new condition((object)[
+            'cm' => (int)$assigncm->id, 'e' => COMPLETION_INCOMPLETE
+        ]);
         $this->assertFalse($cond->is_available(false, $info, true, $user->id));
         $this->assertTrue($cond->is_available(true, $info, true, $user->id));
 
-        $cond = new condition((object)array(
-                'cm' => (int)$assigncm->id, 'e' => COMPLETION_COMPLETE));
+        $cond = new condition((object)[
+            'cm' => (int)$assigncm->id, 'e' => COMPLETION_COMPLETE
+        ]);
         $this->assertTrue($cond->is_available(false, $info, true, $user->id));
         $this->assertFalse($cond->is_available(true, $info, true, $user->id));
 
-        $cond = new condition((object)array(
-                'cm' => (int)$assigncm->id, 'e' => COMPLETION_COMPLETE_PASS));
+        $cond = new condition((object)[
+            'cm' => (int)$assigncm->id, 'e' => COMPLETION_COMPLETE_PASS
+        ]);
         $this->assertFalse($cond->is_available(false, $info, true, $user->id));
         $information = $cond->get_description(false, false, $info);
         $information = \core_availability\info::format_info($information, $course);
         $this->assertRegExp('~Assign!.*is complete and passed~', $information);
         $this->assertTrue($cond->is_available(true, $info, true, $user->id));
 
-        $cond = new condition((object)array(
-                'cm' => (int)$assigncm->id, 'e' => COMPLETION_COMPLETE_FAIL));
+        $cond = new condition((object)[
+            'cm' => (int)$assigncm->id, 'e' => COMPLETION_COMPLETE_FAIL
+        ]);
         $this->assertTrue($cond->is_available(false, $info, true, $user->id));
         $this->assertFalse($cond->is_available(true, $info, true, $user->id));
         $information = $cond->get_description(false, true, $info);
@@ -299,26 +337,30 @@ class availability_completion_condition_testcase extends advanced_testcase {
         // Now change it to pass.
         self::set_grade($assignrow, $user->id, 60);
 
-        $cond = new condition((object)array(
-                'cm' => (int)$assigncm->id, 'e' => COMPLETION_INCOMPLETE));
+        $cond = new condition((object)[
+            'cm' => (int)$assigncm->id, 'e' => COMPLETION_INCOMPLETE
+        ]);
         $this->assertFalse($cond->is_available(false, $info, true, $user->id));
         $this->assertTrue($cond->is_available(true, $info, true, $user->id));
 
-        $cond = new condition((object)array(
-                'cm' => (int)$assigncm->id, 'e' => COMPLETION_COMPLETE));
+        $cond = new condition((object)[
+            'cm' => (int)$assigncm->id, 'e' => COMPLETION_COMPLETE
+        ]);
         $this->assertTrue($cond->is_available(false, $info, true, $user->id));
         $this->assertFalse($cond->is_available(true, $info, true, $user->id));
 
-        $cond = new condition((object)array(
-                'cm' => (int)$assigncm->id, 'e' => COMPLETION_COMPLETE_PASS));
+        $cond = new condition((object)[
+                        'cm' => (int)$assigncm->id, 'e' => COMPLETION_COMPLETE_PASS
+                    ]);
         $this->assertTrue($cond->is_available(false, $info, true, $user->id));
         $this->assertFalse($cond->is_available(true, $info, true, $user->id));
         $information = $cond->get_description(false, true, $info);
         $information = \core_availability\info::format_info($information, $course);
         $this->assertRegExp('~Assign!.*is not complete and passed~', $information);
 
-        $cond = new condition((object)array(
-                'cm' => (int)$assigncm->id, 'e' => COMPLETION_COMPLETE_FAIL));
+        $cond = new condition((object)[
+            'cm' => (int)$assigncm->id, 'e' => COMPLETION_COMPLETE_FAIL
+        ]);
         $this->assertFalse($cond->is_available(false, $info, true, $user->id));
         $information = $cond->get_description(false, false, $info);
         $information = \core_availability\info::format_info($information, $course);
@@ -327,59 +369,390 @@ class availability_completion_condition_testcase extends advanced_testcase {
 
         // Simulate deletion of an activity by using an invalid cmid. These
         // conditions always fail, regardless of NOT flag or INCOMPLETE.
-        $cond = new condition((object)array(
-                'cm' => ($assigncm->id + 100), 'e' => COMPLETION_COMPLETE));
+        $cond = new condition((object)[
+            'cm' => ($assigncm->id + 100), 'e' => COMPLETION_COMPLETE
+        ]);
         $this->assertFalse($cond->is_available(false, $info, true, $user->id));
         $information = $cond->get_description(false, false, $info);
         $information = \core_availability\info::format_info($information, $course);
         $this->assertRegExp('~(Missing activity).*is marked complete~', $information);
         $this->assertFalse($cond->is_available(true, $info, true, $user->id));
-        $cond = new condition((object)array(
-                'cm' => ($assigncm->id + 100), 'e' => COMPLETION_INCOMPLETE));
+        $cond = new condition((object)[
+            'cm' => ($assigncm->id + 100), 'e' => COMPLETION_INCOMPLETE
+        ]);
         $this->assertFalse($cond->is_available(false, $info, true, $user->id));
     }
 
+    /**
+     * Tests the is_available and get_description functions for previous activity option.
+     *
+     * @dataProvider test_previous_activity_data
+     * @param int $grade the current assign grade (0 for none)
+     * @param int $condition true for complete, false for incomplete
+     * @param string $mark activity to mark as complete
+     * @param string $activity activity name to test
+     * @param bool $result if it must be available or not
+     * @param bool $resultnot if it must be available when the condition is inverted
+     * @param string $description the availabiklity text to check
+     */
+    public function test_previous_activity(int $grade, int $condition, string $mark, string $activity,
+            bool $result, bool $resultnot, string $description): void {
+        global $CFG, $DB;
+        require_once($CFG->dirroot . '/mod/assign/locallib.php');
+        $this->resetAfterTest();
+
+        // Create course with completion turned on.
+        $CFG->enablecompletion = true;
+        $CFG->enableavailability = true;
+        $generator = $this->getDataGenerator();
+        $course = $generator->create_course(['enablecompletion' => 1]);
+        $user = $generator->create_user();
+        $generator->enrol_user($user->id, $course->id);
+        $this->setUser($user);
+
+        // Page 1 (manual completion).
+        $page1 = $generator->get_plugin_generator('mod_page')->create_instance(
+                ['course' => $course->id, 'name' => 'Page1!',
+                'completion' => COMPLETION_TRACKING_MANUAL]);
+
+        // Page 2 (manual completion).
+        $page2 = $generator->get_plugin_generator('mod_page')->create_instance(
+                ['course' => $course->id, 'name' => 'Page2!',
+                'completion' => COMPLETION_TRACKING_MANUAL]);
+
+        // Page ignored (no completion).
+        $pagenocompletion = $generator->get_plugin_generator('mod_page')->create_instance(
+                ['course' => $course->id, 'name' => 'Page ignored!']);
+
+        // Create an assignment - we need to have something that can be graded
+        // so as to test the PASS/FAIL states. Set it up to be completed based
+        // on its grade item.
+        $assignrow = $this->getDataGenerator()->create_module('assign', [
+            'course' => $course->id, 'name' => 'Assign!',
+            'completion' => COMPLETION_TRACKING_AUTOMATIC
+        ]);
+        $DB->set_field('course_modules', 'completiongradeitemnumber', 0,
+                ['id' => $assignrow->cmid]);
+        $assign = new assign(context_module::instance($assignrow->cmid), false, false);
+
+        // Page 3 (manual completion).
+        $page3 = $generator->get_plugin_generator('mod_page')->create_instance(
+                ['course' => $course->id, 'name' => 'Page3!',
+                'completion' => COMPLETION_TRACKING_MANUAL]);
+
+        // Get basic details.
+        $activities = [];
+        $modinfo = get_fast_modinfo($course);
+        $activities['page1'] = $modinfo->get_cm($page1->cmid);
+        $activities['page2'] = $modinfo->get_cm($page2->cmid);
+        $activities['assign'] = $assign->get_course_module();
+        $activities['page3'] = $modinfo->get_cm($page3->cmid);
+        $prevvalue = condition::OPTION_PREVIOUS;
+
+        // Setup gradings and completion.
+        if ($grade) {
+            $gradeitem = $assign->get_grade_item();
+            grade_object::set_properties($gradeitem, ['gradepass' => 50.0]);
+            $gradeitem->update();
+            self::set_grade($assignrow, $user->id, $grade);
+        }
+        if ($mark) {
+            $completion = new completion_info($course);
+            $completion->update_state($activities[$mark], COMPLETION_COMPLETE);
+        }
+
+        // Set opprevious WITH non existent previous activity.
+        $info = new \core_availability\mock_info_module($user->id, $activities[$activity]);
+        $cond = new condition((object)[
+            'cm' => (int)$prevvalue, 'e' => $condition
+        ]);
+
+        // Do the checks.
+        $this->assertEquals($result, $cond->is_available(false, $info, true, $user->id));
+        $this->assertEquals($resultnot, $cond->is_available(true, $info, true, $user->id));
+        $information = $cond->get_description(false, false, $info);
+        $information = \core_availability\info::format_info($information, $course);
+        $this->assertRegExp($description, $information);
+    }
+
+    public function test_previous_activity_data(): array {
+        // Assign grade, condition, activity to complete, activity to test, result, resultnot, description.
+        return [
+            'Missing previous activity complete' => [
+                0, COMPLETION_COMPLETE, '', 'page1', false, false, '~Missing activity.*is marked complete~'
+            ],
+            'Missing previous activity incomplete' => [
+                0, COMPLETION_INCOMPLETE, '', 'page1', false, false, '~Missing activity.*is incomplete~'
+            ],
+            'Previous complete condition with previous activity incompleted' => [
+                0, COMPLETION_COMPLETE, '', 'page2', false, true, '~Page1!.*is marked complete~'
+            ],
+            'Previous incomplete condition with previous activity incompleted' => [
+                0, COMPLETION_INCOMPLETE, '', 'page2', true, false, '~Page1!.*is incomplete~'
+            ],
+            'Previous complete condition with previous activity completed' => [
+                0, COMPLETION_COMPLETE, 'page1', 'page2', true, false, '~Page1!.*is marked complete~'
+            ],
+            'Previous incomplete condition with previous activity completed' => [
+                0, COMPLETION_INCOMPLETE, 'page1', 'page2', false, true, '~Page1!.*is incomplete~'
+            ],
+            // Depenging on page pass fail (pages are not gradable).
+            'Previous complete pass condition with previous no gradable activity incompleted' => [
+                0, COMPLETION_COMPLETE_PASS, '', 'page2', false, true, '~Page1!.*is complete and passed~'
+            ],
+            'Previous complete fail condition with previous no gradable activity incompleted' => [
+                0, COMPLETION_COMPLETE_FAIL, '', 'page2', false, true, '~Page1!.*is complete and failed~'
+            ],
+            'Previous complete pass condition with previous no gradable activity completed' => [
+                0, COMPLETION_COMPLETE_PASS, 'page1', 'page2', false, true, '~Page1!.*is complete and passed~'
+            ],
+            'Previous complete fail condition with previous no gradable activity completed' => [
+                0, COMPLETION_COMPLETE_FAIL, 'page1', 'page2', false, true, '~Page1!.*is complete and failed~'
+            ],
+            // There's an page without completion between page2 ans assign.
+            'Previous complete condition with sibling activity incompleted' => [
+                0, COMPLETION_COMPLETE, '', 'assign', false, true, '~Page2!.*is marked complete~'
+            ],
+            'Previous incomplete condition with sibling activity incompleted' => [
+                0, COMPLETION_INCOMPLETE, '', 'assign', true, false, '~Page2!.*is incomplete~'
+            ],
+            'Previous complete condition with sibling activity completed' => [
+                0, COMPLETION_COMPLETE, 'page2', 'assign', true, false, '~Page2!.*is marked complete~'
+            ],
+            'Previous incomplete condition with sibling activity completed' => [
+                0, COMPLETION_INCOMPLETE, 'page2', 'assign', false, true, '~Page2!.*is incomplete~'
+            ],
+            // Depending on assign without grade.
+            'Previous complete condition with previous without grade' => [
+                0, COMPLETION_COMPLETE, '', 'page3', false, true, '~Assign!.*is marked complete~'
+            ],
+            'Previous incomplete condition with previous without grade' => [
+                0, COMPLETION_INCOMPLETE, '', 'page3', true, false, '~Assign!.*is incomplete~'
+            ],
+            'Previous complete pass condition with previous without grade' => [
+                0, COMPLETION_COMPLETE_PASS, '', 'page3', false, true, '~Assign!.*is complete and passed~'
+            ],
+            'Previous complete fail condition with previous without grade' => [
+                0, COMPLETION_COMPLETE_FAIL, '', 'page3', false, true, '~Assign!.*is complete and failed~'
+            ],
+            // Depending on assign with grade.
+            'Previous complete condition with previous fail grade' => [
+                40, COMPLETION_COMPLETE, '', 'page3', true, false, '~Assign!.*is marked complete~'
+            ],
+            'Previous incomplete condition with previous fail grade' => [
+                40, COMPLETION_INCOMPLETE, '', 'page3', false, true, '~Assign!.*is incomplete~'
+            ],
+            'Previous complete pass condition with previous fail grade' => [
+                40, COMPLETION_COMPLETE_PASS, '', 'page3', false, true, '~Assign!.*is complete and passed~'
+            ],
+            'Previous complete fail condition with previous fail grade' => [
+                40, COMPLETION_COMPLETE_FAIL, '', 'page3', true, false, '~Assign!.*is complete and failed~'
+            ],
+            'Previous complete condition with previous pass grade' => [
+                60, COMPLETION_COMPLETE, '', 'page3', true, false, '~Assign!.*is marked complete~'
+            ],
+            'Previous incomplete condition with previous pass grade' => [
+                60, COMPLETION_INCOMPLETE, '', 'page3', false, true, '~Assign!.*is incomplete~'
+            ],
+            'Previous complete pass condition with previous pass grade' => [
+                60, COMPLETION_COMPLETE_PASS, '', 'page3', true, false, '~Assign!.*is complete and passed~'
+            ],
+            'Previous complete fail condition with previous pass grade' => [
+                60, COMPLETION_COMPLETE_FAIL, '', 'page3', false, true, '~Assign!.*is complete and failed~'
+            ],
+        ];
+    }
+
+    /**
+     * Tests the is_available and get_description functions for
+     * previous activity option in course sections.
+     *
+     * @dataProvider test_section_previous_activity_data
+     * @param int $condition condition value
+     * @param bool $mark if Page 1 must be mark as completed
+     * @param string $section section to add the availability
+     * @param bool $result expected result
+     * @param bool $resultnot expected negated result
+     * @param string $description description to match
+     */
+    public function test_section_previous_activity(int $condition, bool $mark, string $section,
+                bool $result, bool $resultnot, string $description): void {
+        global $CFG, $DB;
+        require_once($CFG->dirroot . '/mod/assign/locallib.php');
+        $this->resetAfterTest();
+
+        // Create course with completion turned on.
+        $CFG->enablecompletion = true;
+        $CFG->enableavailability = true;
+        $generator = $this->getDataGenerator();
+        $course = $generator->create_course(
+                ['numsections' => 4, 'enablecompletion' => 1],
+                ['createsections' => true]);
+        $user = $generator->create_user();
+        $generator->enrol_user($user->id, $course->id);
+        $this->setUser($user);
+
+        // Section 1 - page1 (manual completion).
+        $page1 = $generator->get_plugin_generator('mod_page')->create_instance(
+                ['course' => $course->id, 'name' => 'Page1!', 'section' => 1,
+                'completion' => COMPLETION_TRACKING_MANUAL]);
+
+        // Section 1 - page ignored 1 (no completion).
+        $pagenocompletion1 = $generator->get_plugin_generator('mod_page')->create_instance(
+                ['course' => $course, 'name' => 'Page ignored!', 'section' => 1]);
+
+        // Section 2 - page ignored 2 (no completion).
+        $pagenocompletion2 = $generator->get_plugin_generator('mod_page')->create_instance(
+                ['course' => $course, 'name' => 'Page ignored!', 'section' => 2]);
+
+        // Section 3 - page2 (manual completion).
+        $page2 = $generator->get_plugin_generator('mod_page')->create_instance(
+                ['course' => $course->id, 'name' => 'Page2!', 'section' => 3,
+                'completion' => COMPLETION_TRACKING_MANUAL]);
+
+        // Section 4 is empty.
+
+        // Get basic details.
+        get_fast_modinfo(0, 0, true);
+        $modinfo = get_fast_modinfo($course);
+        $sections['section1'] = $modinfo->get_section_info(1);
+        $sections['section2'] = $modinfo->get_section_info(2);
+        $sections['section3'] = $modinfo->get_section_info(3);
+        $sections['section4'] = $modinfo->get_section_info(4);
+        $page1cm = $modinfo->get_cm($page1->cmid);
+        $prevvalue = condition::OPTION_PREVIOUS;
+
+        if ($mark) {
+            // Mark page1 complete.
+            $completion = new completion_info($course);
+            $completion->update_state($page1cm, COMPLETION_COMPLETE);
+        }
+
+        $info = new \core_availability\mock_info_section($user->id, $sections[$section]);
+        $cond = new condition((object)[
+            'cm' => (int)$prevvalue, 'e' => $condition
+        ]);
+        $this->assertEquals($result, $cond->is_available(false, $info, true, $user->id));
+        $this->assertEquals($resultnot, $cond->is_available(true, $info, true, $user->id));
+        $information = $cond->get_description(false, false, $info);
+        $information = \core_availability\info::format_info($information, $course);
+        $this->assertRegExp($description, $information);
+
+    }
+
+    public function test_section_previous_activity_data(): array {
+        return [
+            // Condition, Activity completion, section to test, result, resultnot, description.
+            'Completion complete Section with no previous activity' => [
+                COMPLETION_COMPLETE, false, 'section1', false, false, '~Missing activity.*is marked complete~'
+            ],
+            'Completion incomplete Section with no previous activity' => [
+                COMPLETION_INCOMPLETE, false, 'section1', false, false, '~Missing activity.*is incomplete~'
+            ],
+            // Section 2 depending on section 1 -> Page 1 (no grading).
+            'Completion complete Section with previous activity incompleted' => [
+                COMPLETION_COMPLETE, false, 'section2', false, true, '~Page1!.*is marked complete~'
+            ],
+            'Completion incomplete Section with previous activity incompleted' => [
+                COMPLETION_INCOMPLETE, false, 'section2', true, false, '~Page1!.*is incomplete~'
+            ],
+            'Completion complete Section with previous activity completed' => [
+                COMPLETION_COMPLETE, true, 'section2', true, false, '~Page1!.*is marked complete~'
+            ],
+            'Completion incomplete Section with previous activity completed' => [
+                COMPLETION_INCOMPLETE, true, 'section2', false, true, '~Page1!.*is incomplete~'
+            ],
+            // Section 3 depending on section 1 -> Page 1 (no grading).
+            'Completion complete Section ignoring empty sections and activity incompleted' => [
+                COMPLETION_COMPLETE, false, 'section3', false, true, '~Page1!.*is marked complete~'
+            ],
+            'Completion incomplete Section ignoring empty sections and activity incompleted' => [
+                COMPLETION_INCOMPLETE, false, 'section3', true, false, '~Page1!.*is incomplete~'
+            ],
+            'Completion complete Section ignoring empty sections and activity completed' => [
+                COMPLETION_COMPLETE, true, 'section3', true, false, '~Page1!.*is marked complete~'
+            ],
+            'Completion incomplete Section ignoring empty sections and activity completed' => [
+                COMPLETION_INCOMPLETE, true, 'section3', false, true, '~Page1!.*is incomplete~'
+            ],
+            // Section 4 depending on section 3 -> Page 2 (no grading).
+            'Completion complete Last section with previous activity incompleted' => [
+                COMPLETION_COMPLETE, false, 'section4', false, true, '~Page2!.*is marked complete~'
+            ],
+            'Completion incomplete Last section with previous activity incompleted' => [
+                COMPLETION_INCOMPLETE, false, 'section4', true, false, '~Page2!.*is incomplete~'
+            ],
+            'Completion complete Last section with previous activity completed' => [
+                COMPLETION_COMPLETE, true, 'section4', false, true, '~Page2!.*is marked complete~'
+            ],
+            'Completion incomplete Last section with previous activity completed' => [
+                COMPLETION_INCOMPLETE, true, 'section4', true, false, '~Page2!.*is incomplete~'
+            ],
+        ];
+    }
+
     /**
      * Tests completion_value_used static function.
      */
     public function test_completion_value_used() {
         global $CFG, $DB;
         $this->resetAfterTest();
+        $prevvalue = condition::OPTION_PREVIOUS;
 
         // Create course with completion turned on and some sections.
         $CFG->enablecompletion = true;
         $CFG->enableavailability = true;
         $generator = $this->getDataGenerator();
         $course = $generator->create_course(
-                array('numsections' => 1, 'enablecompletion' => 1),
-                array('createsections' => true));
-        availability_completion\condition::wipe_static_cache();
+                ['numsections' => 1, 'enablecompletion' => 1],
+                ['createsections' => true]);
 
-        // Create three pages with manual completion.
+        // Create six pages with manual completion.
         $page1 = $generator->get_plugin_generator('mod_page')->create_instance(
-                array('course' => $course->id, 'completion' => COMPLETION_TRACKING_MANUAL));
+                ['course' => $course->id, 'completion' => COMPLETION_TRACKING_MANUAL]);
         $page2 = $generator->get_plugin_generator('mod_page')->create_instance(
-                array('course' => $course->id, 'completion' => COMPLETION_TRACKING_MANUAL));
+                ['course' => $course->id, 'completion' => COMPLETION_TRACKING_MANUAL]);
         $page3 = $generator->get_plugin_generator('mod_page')->create_instance(
-                array('course' => $course->id, 'completion' => COMPLETION_TRACKING_MANUAL));
+                ['course' => $course->id, 'completion' => COMPLETION_TRACKING_MANUAL]);
+        $page4 = $generator->get_plugin_generator('mod_page')->create_instance(
+                ['course' => $course->id, 'completion' => COMPLETION_TRACKING_MANUAL]);
+        $page5 = $generator->get_plugin_generator('mod_page')->create_instance(
+                ['course' => $course->id, 'completion' => COMPLETION_TRACKING_MANUAL]);
+        $page6 = $generator->get_plugin_generator('mod_page')->create_instance(
+                ['course' => $course->id, 'completion' => COMPLETION_TRACKING_MANUAL]);
 
         // Set up page3 to depend on page1, and section1 to depend on page2.
         $DB->set_field('course_modules', 'availability',
                 '{"op":"|","show":true,"c":[' .
                 '{"type":"completion","e":1,"cm":' . $page1->cmid . '}]}',
-                array('id' => $page3->cmid));
+                ['id' => $page3->cmid]);
         $DB->set_field('course_sections', 'availability',
                 '{"op":"|","show":true,"c":[' .
                 '{"type":"completion","e":1,"cm":' . $page2->cmid . '}]}',
-                array('course' => $course->id, 'section' => 1));
+                ['course' => $course->id, 'section' => 1]);
+        // Set up page5 and page6 to depend on previous activity.
+        $DB->set_field('course_modules', 'availability',
+                '{"op":"|","show":true,"c":[' .
+                '{"type":"completion","e":1,"cm":' . $prevvalue . '}]}',
+                ['id' => $page5->cmid]);
+        $DB->set_field('course_modules', 'availability',
+                '{"op":"|","show":true,"c":[' .
+                '{"type":"completion","e":1,"cm":' . $prevvalue . '}]}',
+                ['id' => $page6->cmid]);
 
-        // Now check: nothing depends on page3 but something does on the others.
+        // Check 1: nothing depends on page3 and page6 but something does on the others.
         $this->assertTrue(availability_completion\condition::completion_value_used(
                 $course, $page1->cmid));
         $this->assertTrue(availability_completion\condition::completion_value_used(
                 $course, $page2->cmid));
         $this->assertFalse(availability_completion\condition::completion_value_used(
                 $course, $page3->cmid));
+        $this->assertTrue(availability_completion\condition::completion_value_used(
+                $course, $page4->cmid));
+        $this->assertTrue(availability_completion\condition::completion_value_used(
+                $course, $page5->cmid));
+        $this->assertFalse(availability_completion\condition::completion_value_used(
+                $course, $page6->cmid));
     }
 
     /**
@@ -390,9 +763,10 @@ class availability_completion_condition_testcase extends advanced_testcase {
      * @param float $grade Grade
      */
     protected static function set_grade($assignrow, $userid, $grade) {
-        $grades = array();
-        $grades[$userid] = (object)array(
-                'rawgrade' => $grade, 'userid' => $userid);
+        $grades = [];
+        $grades[$userid] = (object)[
+            'rawgrade' => $grade, 'userid' => $userid
+        ];
         $assignrow->cmidnumber = null;
         assign_grade_item_update($assignrow, $grades);
     }
@@ -401,12 +775,32 @@ class availability_completion_condition_testcase extends advanced_testcase {
      * Tests the update_dependency_id() function.
      */
     public function test_update_dependency_id() {
-        $cond = new condition((object)array(
-                'cm' => 123, 'e' => COMPLETION_COMPLETE));
-        $this->assertFalse($cond->update_dependency_id('frogs', 123, 456));
+        $cond = new condition((object)[
+            'cm' => 42, 'e' => COMPLETION_COMPLETE, 'selfid' => 43
+        ]);
+        $this->assertFalse($cond->update_dependency_id('frogs', 42, 540));
         $this->assertFalse($cond->update_dependency_id('course_modules', 12, 34));
-        $this->assertTrue($cond->update_dependency_id('course_modules', 123, 456));
+        $this->assertTrue($cond->update_dependency_id('course_modules', 42, 456));
         $after = $cond->save();
         $this->assertEquals(456, $after->cm);
+
+        // Test selfid updating.
+        $cond = new condition((object)[
+            'cm' => 42, 'e' => COMPLETION_COMPLETE
+        ]);
+        $this->assertFalse($cond->update_dependency_id('frogs', 43, 540));
+        $this->assertFalse($cond->update_dependency_id('course_modules', 12, 34));
+        $after = $cond->save();
+        $this->assertEquals(42, $after->cm);
+
+        // Test on previous activity.
+        $cond = new condition((object)[
+            'cm' => condition::OPTION_PREVIOUS,
+            'e' => COMPLETION_COMPLETE
+        ]);
+        $this->assertFalse($cond->update_dependency_id('frogs', 43, 80));
+        $this->assertFalse($cond->update_dependency_id('course_modules', 12, 34));
+        $after = $cond->save();
+        $this->assertEquals(condition::OPTION_PREVIOUS, $after->cm);
     }
 }
index 71d6281..4e1a55f 100644 (file)
@@ -24,6 +24,6 @@
 
 defined('MOODLE_INTERNAL') || die();
 
-$plugin->version = 2019111800;
+$plugin->version = 2020032600;
 $plugin->requires = 2019111200;
 $plugin->component = 'availability_completion';
diff --git a/availability/tests/fixtures/mock_info_module.php b/availability/tests/fixtures/mock_info_module.php
new file mode 100644 (file)
index 0000000..47f75cc
--- /dev/null
@@ -0,0 +1,115 @@
+<?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/>.
+
+/**
+ * For use in unit tests that require an info module which isn't really used.
+ *
+ * @package core_availability
+ * @copyright 2019 Ferran Recio
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core_availability;
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * For use in unit tests that require an info module which isn't really used.
+ *
+ * @package core_availability
+ * @copyright 2019 Ferran Recio <ferran@moodle.com>
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class mock_info_module extends info_module {
+    /** @var int User id for modinfo */
+    protected $userid;
+
+    /** @var \cm_info Activity. */
+    protected $cm;
+
+    /**
+     * Constructs with item details.
+     *
+     * @param int $userid Userid for modinfo (if used)
+     * @param \cm_info $cm Course-module object
+     */
+    public function __construct($userid = 0, \cm_info $cm = null) {
+        parent::__construct($cm);
+        $this->userid = $userid;
+        $this->cm = $cm;
+    }
+
+    /**
+     * Just returns a mock name.
+     *
+     * @return string Name of item
+     */
+    protected function get_thing_name() {
+        return 'Mock Module';
+    }
+
+    /**
+     * Returns the current context.
+     *
+     * @return \context Context for this item
+     */
+    public function get_context() {
+        return \context_course::instance($this->get_course()->id);
+    }
+
+    /**
+     * Returns the cappability used to ignore access restrictions.
+     *
+     * @return string Name of capability used to view hidden items of this type
+     */
+    protected function get_view_hidden_capability() {
+        return 'moodle/course:ignoreavailabilityrestrictions';
+    }
+
+    /**
+     * Mocks don't need to save anything into DB.
+     *
+     * @param string $availability New JSON value
+     */
+    protected function set_in_database($availability) {
+    }
+
+    /**
+     * Obtains the modinfo associated with this availability information.
+     *
+     * Note: This field is available ONLY for use by conditions when calculating
+     * availability or information.
+     *
+     * @return \course_modinfo Modinfo
+     * @throws \coding_exception If called at incorrect times
+     */
+    public function get_modinfo() {
+        // Allow modinfo usage outside is_available etc., so we can use this
+        // to directly call into condition is_available.
+        if (!$this->userid) {
+            throw new \coding_exception('Need to set mock_info userid');
+        }
+        return get_fast_modinfo($this->course, $this->userid);
+    }
+
+    /**
+     * Override course-module info.
+     * @param \cm_info $cm
+     */
+    public function set_cm (\cm_info $cm) {
+        $this->cm = $cm;
+    }
+}
diff --git a/availability/tests/fixtures/mock_info_section.php b/availability/tests/fixtures/mock_info_section.php
new file mode 100644 (file)
index 0000000..15fba45
--- /dev/null
@@ -0,0 +1,116 @@
+<?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/>.
+
+/**
+ * For use in unit tests that require an info section which isn't really used.
+ *
+ * @package core_availability
+ * @copyright 2019 Ferran Recio <ferran@moodle.com>
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core_availability;
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * For use in unit tests that require an info section which isn't really used.
+ *
+ * @package core_availability
+ * @copyright 2019 Ferran Recio
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class mock_info_section extends info_section {
+    /** @var int User id for modinfo */
+    protected $userid;
+
+    /** @var \section_info Section. */
+    protected $section;
+
+    /**
+     * Constructs with item details.
+     *
+     * @param int $userid Userid for modinfo (if used)
+     * @param \section_info $section Section object
+     */
+    public function __construct($userid = 0, \section_info $section = null) {
+        parent::__construct($section);
+        $this->userid = $userid;
+        $this->section = $section;
+    }
+
+    /**
+     * Just returns a mock name.
+     *
+     * @return string Name of item
+     */
+    protected function get_thing_name() {
+        return 'Mock Section';
+    }
+
+    /**
+     * Returns the current context.
+     *
+     * @return \context Context for this item
+     */
+    public function get_context() {
+        return \context_course::instance($this->get_course()->id);
+    }
+
+    /**
+     * Returns the cappability used to ignore access restrictions.
+     *
+     * @return string Name of capability used to view hidden items of this type
+     */
+    protected function get_view_hidden_capability() {
+        return 'moodle/course:ignoreavailabilityrestrictions';
+    }
+
+    /**
+     * Mocks don't need to save anything into DB.
+     *
+     * @param string $availability New JSON value
+     */
+    protected function set_in_database($availability) {
+    }
+
+    /**
+     * Obtains the modinfo associated with this availability information.
+     *
+     * Note: This field is available ONLY for use by conditions when calculating
+     * availability or information.
+     *
+     * @return \course_modinfo Modinfo
+     * @throws \coding_exception If called at incorrect times
+     */
+    public function get_modinfo() {
+        // Allow modinfo usage outside is_available etc., so we can use this
+        // to directly call into condition is_available.
+        if (!$this->userid) {
+            throw new \coding_exception('Need to set mock_info userid');
+        }
+        return get_fast_modinfo($this->course, $this->userid);
+    }
+
+    /**
+     * Override section info.
+     *
+     * @param \section_info $section
+     */
+    public function set_section (\section_info $section) {
+        $this->section = $section;
+    }
+}
index 2e96eeb..c5f86bb 100644 (file)
@@ -53,7 +53,7 @@ class block_recent_activity_renderer extends plugin_renderer_base {
         $output .= html_writer::tag('div',
                 html_writer::link(new moodle_url('/course/recent.php', array('id' => $course->id)),
                     get_string('recentactivityreport')),
-                array('class' => 'activityhead'));
+                array('class' => 'activityhead mb-3'));
 
         $content = false;
 
@@ -79,7 +79,7 @@ class block_recent_activity_renderer extends plugin_renderer_base {
         // Next, have there been any modifications to the course structure?
         if (!empty($structuralchanges)) {
             $content = true;
-            $output .= $this->heading(get_string("courseupdates").':', 3);
+            $output .= $this->heading(get_string("courseupdates") . ':', 6);
             foreach ($structuralchanges as $changeinfo => $change) {
                 $output .= $this->structural_change($change);
             }
index 3d1d9e3..f1e2c2a 100644 (file)
@@ -1,8 +1,3 @@
-.block_recent_activity .activitydate,
-.block_recent_activity .activityhead {
-    text-align: center;
-}
-
 .block_recent_activity .unlist li {
     margin-bottom: 1em;
 }
index 27975cb..25bc2dd 100644 (file)
@@ -29,6 +29,7 @@ use stored_file;
 use stdClass;
 use coding_exception;
 use moodle_url;
+use core\event\contentbank_content_updated;
 
 /**
  * Content manager class
@@ -100,7 +101,13 @@ abstract class content {
         }
         $this->content->usermodified = $USER->id;
         $this->content->timemodified = time();
-        return $DB->update_record('contentbank_content', $this->content);
+        $result = $DB->update_record('contentbank_content', $this->content);
+        if ($result) {
+            // Trigger an event for updating this content.
+            $event = contentbank_content_updated::create_from_record($this->content);
+            $event->trigger();
+        }
+        return $result;
     }
 
     /**
index 0a21353..35499ce 100644 (file)
@@ -24,6 +24,8 @@
 
 namespace core_contentbank;
 
+use stored_file;
+
 /**
  * Content bank class
  *
@@ -202,4 +204,32 @@ class contentbank {
 
         return $contents;
     }
+
+    /**
+     * Create content from a file information.
+     *
+     * @param \context $context Context where to upload the file and content.
+     * @param int $userid Id of the user uploading the file.
+     * @param stored_file $file The file to get information from
+     * @return content
+     */
+    public function create_content_from_file(\context $context, int $userid, stored_file $file): ?content {
+        global $USER;
+        if (empty($userid)) {
+            $userid = $USER->id;
+        }
+        // Get the contenttype to manage given file's extension.
+        $filename = $file->get_filename();
+        $extension = $this->get_extension($filename);
+        $plugin = $this->get_extension_supporter($extension, $context);
+        $classname = '\\contenttype_'.$plugin.'\\contenttype';
+        $record = new \stdClass();
+        $record->name = $filename;
+        $record->usercreated = $userid;
+        $contentype = new $classname($context);
+        $content = $contentype->create_content($record);
+        $event = \core\event\contentbank_content_uploaded::create_from_record($content->get_content());
+        $event->trigger();
+        return $content;
+    }
 }
index 05922b7..7f7fbf5 100644 (file)
@@ -24,6 +24,9 @@
 
 namespace core_contentbank;
 
+use core\event\contentbank_content_created;
+use core\event\contentbank_content_deleted;
+use core\event\contentbank_content_viewed;
 use moodle_url;
 
 /**
@@ -71,10 +74,15 @@ abstract class contenttype {
         $entry->usermodified = $entry->usercreated;
         $entry->timemodified = $entry->timecreated;
         $entry->configdata = $record->configdata ?? '';
+        $entry->instanceid = $record->instanceid ?? 0;
         $entry->id = $DB->insert_record('contentbank_content', $entry);
         if ($entry->id) {
             $classname = '\\'.$entry->contenttype.'\\content';
-            return new $classname($entry);
+            $content = new $classname($entry);
+            // Trigger an event for creating the content.
+            $event = contentbank_content_created::create_from_record($content->get_content());
+            $event->trigger();
+            return $content;
         }
         return null;
     }
@@ -95,7 +103,23 @@ abstract class contenttype {
         }
 
         // Delete the contentbank DB entry.
-        return $DB->delete_records('contentbank_content', ['id' => $content->get_id()]);
+        $result = $DB->delete_records('contentbank_content', ['id' => $content->get_id()]);
+        if ($result) {
+            // Trigger an event for deleting this content.
+            $record = $content->get_content();
+            $event = contentbank_content_deleted::create([
+                'objectid' => $content->get_id(),
+                'relateduserid' => $record->usercreated,
+                'context' => \context::instance_by_id($record->contextid),
+                'other' => [
+                    'contenttype' => $content->get_content_type(),
+                    'name' => $content->get_name()
+                ]
+            ]);
+            $event->add_record_snapshot('contentbank_content', $record);
+            $event->trigger();
+        }
+        return $result;
     }
 
     /**
@@ -149,6 +173,10 @@ abstract class contenttype {
      * @return string           HTML code to include in view.php.
      */
     public function get_view_content(\stdClass $record): string {
+        // Trigger an event for viewing this content.
+        $event = contentbank_content_viewed::create_from_record($record);
+        $event->trigger();
+
         // Main contenttype class can visualize the content, but plugins could overwrite visualization.
         return '';
     }
index f28bee8..806205d 100644 (file)
@@ -24,6 +24,7 @@
 
 namespace contenttype_h5p;
 
+use core\event\contentbank_content_viewed;
 use stdClass;
 use html_writer;
 
@@ -45,7 +46,9 @@ class contenttype extends \core_contentbank\contenttype {
     public function delete_content(\core_contentbank\content $content): bool {
         // Delete the H5P content.
         $factory = new \core_h5p\factory();
-        \core_h5p\api::delete_content_from_pluginfile_url($content->get_file_url(), $factory);
+        if (!empty($content->get_file_url())) {
+            \core_h5p\api::delete_content_from_pluginfile_url($content->get_file_url(), $factory);
+        }
 
         // Delete the content from the content_bank.
         return parent::delete_content($content);
@@ -58,6 +61,10 @@ class contenttype extends \core_contentbank\contenttype {
      * @return string            HTML code to include in view.php.
      */
     public function get_view_content(\stdClass $record): string {
+        // Trigger an event for viewing this content.
+        $event = contentbank_content_viewed::create_from_record($record);
+        $event->trigger();
+
         $content = new content($record);
         $fileurl = $content->get_file_url();
         $html = html_writer::tag('h2', $content->get_name());
diff --git a/contentbank/tests/behat/events.feature b/contentbank/tests/behat/events.feature
new file mode 100644 (file)
index 0000000..c520199
--- /dev/null
@@ -0,0 +1,73 @@
+@core @core_contentbank @_file_upload @_switch_iframe @javascript
+Feature: Confirm content bank events are triggered
+  In order to log content bank actions
+  As an admin
+  I need to be able to check triggered events
+
+  Background:
+    And the following "courses" exist:
+      | fullname | shortname | category |
+      | Course 1 | C1        | 0        |
+    And the following "contentbank content" exist:
+      | course | contenttype     | user  | contentname |
+      | C1     | contenttype_h5p | admin | Existing    |
+    And I log in as "admin"
+    And I follow "Manage private files..."
+    And I upload "h5p/tests/fixtures/filltheblanks.h5p" file to "Files" filemanager
+    And I click on "Save changes" "button"
+    And I am on "Course 1" course homepage with editing mode on
+    And I add the "Navigation" block if not present
+
+  Scenario: Content created and uploaded events when uploading a content file
+    Given I navigate to "Reports > Live logs" in site administration
+    And I should not see "Content uploaded"
+    And I should not see "Content created"
+    And I am on "Course 1" course homepage
+    And I expand "Site pages" node
+    And I click on "Content bank" "link"
+    When I click on "Upload" "link"
+    And I click on "Choose a file..." "button"
+    And I click on "Private files" "link" in the ".fp-repo-area" "css_element"
+    And I click on "filltheblanks.h5p" "link"
+    And I click on "Select this file" "button"
+    And I click on "Save changes" "button"
+    And I navigate to "Reports > Live logs" in site administration
+    Then I should see "Content uploaded"
+    And I should see "Content created"
+
+  Scenario: Content viewed event
+    Given I navigate to "Reports > Live logs" in site administration
+    And I should not see "Content viewed"
+    And I am on "Course 1" course homepage
+    And I expand "Site pages" node
+    And I click on "Content bank" "link"
+    When I click on "Existing" "link"
+    And I navigate to "Reports > Live logs" in site administration
+    Then I should see "Content viewed"
+
+  Scenario: Content deleted event
+    Given I navigate to "Reports > Live logs" in site administration
+    And I should not see "Content deleted"
+    And I am on "Course 1" course homepage
+    And I expand "Site pages" node
+    And I click on "Content bank" "link"
+    And I click on "Existing" "link"
+    And I open the action menu in "region-main-settings-menu" "region"
+    When I choose "Delete" in the open action menu
+    And I click on "Delete" "button" in the "Delete content" "dialogue"
+    And I navigate to "Reports > Live logs" in site administration
+    Then I should see "Content deleted"
+
+  Scenario: Content updated event when renaming
+    Given I navigate to "Reports > Live logs" in site administration
+    And I should not see "Content updated"
+    And I am on "Course 1" course homepage
+    And I expand "Site pages" node
+    And I click on "Content bank" "link"
+    And I click on "Existing" "link"
+    And I open the action menu in "region-main-settings-menu" "region"
+    When I choose "Rename" in the open action menu
+    And I set the field "Content name" to "New name"
+    And I click on "Rename" "button"
+    And I navigate to "Reports > Live logs" in site administration
+    Then I should see "Content updated"
\ No newline at end of file
index 824f7a5..e420456 100644 (file)
@@ -181,11 +181,11 @@ class core_contentbank_testcase extends advanced_testcase {
      *
      * @dataProvider search_contents_provider
      * @param  string $search String to search.
-     * @param  int $contextid Contextid to search.
+     * @param  string $where Context where to search.
      * @param  int $expectedresult Expected result.
      * @param  array $contexts List of contexts where to create content.
      */
-    public function test_search_contents(?string $search, int $contextid, int $expectedresult, array $contexts = []): void {
+    public function test_search_contents(?string $search, string $where, int $expectedresult, array $contexts = []): void {
         global $DB;
 
         $this->resetAfterTest();
@@ -195,11 +195,26 @@ class core_contentbank_testcase extends advanced_testcase {
         $manager = $this->getDataGenerator()->create_user();
         $this->getDataGenerator()->role_assign($managerroleid, $manager->id);
 
+        // Create a category and a course.
+        $coursecat = $this->getDataGenerator()->create_category();
+        $course = $this->getDataGenerator()->create_course();
+        $existingcontexts = [];
+        $existingcontexts['system'] = \context_system::instance();
+        $existingcontexts['category'] = \context_coursecat::instance($coursecat->id);
+        $existingcontexts['course'] = \context_course::instance($course->id);
+
+        if (empty($where)) {
+            $contextid = 0;
+        } else {
+            $contextid = $existingcontexts[$where]->id;
+        }
+
         // Add some content to the content bank.
         $generator = $this->getDataGenerator()->get_plugin_generator('core_contentbank');
         foreach ($contexts as $context) {
+            $contextinstance = $existingcontexts[$context];
             $records = $generator->generate_contentbank_data('contenttype_h5p', 3,
-                $manager->id, $context, false);
+                $manager->id, $contextinstance, false);
         }
 
         // Search for some content.
@@ -220,98 +235,126 @@ class core_contentbank_testcase extends advanced_testcase {
      * @return array
      */
     public function search_contents_provider(): array {
-        // Create a category and a course.
-        $systemcontext = \context_system::instance();
-        $coursecat = $this->getDataGenerator()->create_category();
-        $course = $this->getDataGenerator()->create_course();
-        $coursecatcontext = \context_coursecat::instance($coursecat->id);
-        $coursecontext = \context_course::instance($course->id);
 
         return [
             'Search all content in all contexts' => [
                 null,
-                0,
+                '',
                 9,
-                [$systemcontext, $coursecatcontext, $coursecontext]
+                ['system', 'category', 'course']
             ],
             'Search in all contexts for existing string in all contents' => [
                 'content',
-                0,
+                '',
                 9,
-                [$systemcontext, $coursecatcontext, $coursecontext]
+                ['system', 'category', 'course']
             ],
             'Search in all contexts for unexisting string in all contents' => [
                 'chocolate',
+                '',
                 0,
-                0,
-                [$systemcontext, $coursecatcontext, $coursecontext]
+                ['system', 'category', 'course']
             ],
             'Search in all contexts for existing string in some contents' => [
                 '1',
-                0,
+                '',
                 3,
-                [$systemcontext, $coursecatcontext, $coursecontext]
+                ['system', 'category', 'course']
             ],
             'Search in all contexts for existing string in some contents (create only 1 context)' => [
                 '1',
-                0,
+                '',
                 1,
-                [$systemcontext]
+                ['system']
             ],
             'Search in system context for existing string in all contents' => [
                 'content',
-                $systemcontext->id,
+                'system',
                 3,
-                [$systemcontext, $coursecatcontext, $coursecontext]
+                ['system', 'category', 'course']
             ],
             'Search in category context for unexisting string in all contents' => [
                 'chocolate',
-                $coursecatcontext->id,
+                'category',
                 0,
-                [$systemcontext, $coursecatcontext, $coursecontext]
+                ['system', 'category', 'course']
             ],
             'Search in course context for existing string in some contents' => [
                 '1',
-                $coursecontext->id,
+                'course',
                 1,
-                [$systemcontext, $coursecatcontext, $coursecontext]
+                ['system', 'category', 'course']
             ],
             'Search in system context' => [
                 null,
-                $systemcontext->id,
+                'system',
                 3,
-                [$systemcontext, $coursecatcontext, $coursecontext]
+                ['system', 'category', 'course']
             ],
             'Search in course context with existing content' => [
                 null,
-                $coursecontext->id,
+                'course',
                 3,
-                [$systemcontext, $coursecatcontext, $coursecontext]
+                ['system', 'category', 'course']
             ],
             'Search in course context without existing content' => [
                 null,
-                $coursecontext->id,
+                'course',
                 0,
-                [$systemcontext, $coursecatcontext]
+                ['system', 'category']
             ],
             'Search in an empty contentbank' => [
                 null,
-                0,
+                '',
                 0,
                 []
             ],
             'Search in a context in an empty contentbank' => [
                 null,
-                $systemcontext->id,
+                'system',
                 0,
                 []
             ],
             'Search for a string in an empty contentbank' => [
                 'content',
-                0,
+                '',
                 0,
                 []
             ],
         ];
     }
+
+    /**
+     * Test create_content_from_file function.
+     *
+     * @covers ::create_content_from_file
+     */
+    public function test_create_content_from_file() {
+        global $USER;
+
+        $this->resetAfterTest();
+        $this->setAdminUser();
+        $systemcontext = \context_system::instance();
+        $name = 'dummy_h5p.h5p';
+
+        // Create a dummy H5P file.
+        $dummyh5p = array(
+            'contextid' => $systemcontext->id,
+            'component' => 'contentbank',
+            'filearea' => 'public',
+            'itemid' => 1,
+            'filepath' => '/',
+            'filename' => $name,
+            'userid' => $USER->id
+        );
+        $fs = get_file_storage();
+        $dummyh5pfile = $fs->create_file_from_string($dummyh5p, 'Dummy H5Pcontent');
+
+        $cb = new \core_contentbank\contentbank();
+        $content = $cb->create_content_from_file($systemcontext, $USER->id, $dummyh5pfile);
+
+        $this->assertEquals('contenttype_h5p', $content->get_content_type());
+        $this->assertInstanceOf('\\contenttype_h5p\\content', $content);
+        $this->assertEquals($name, $content->get_name());
+    }
 }
index 48b651e..0a88a69 100644 (file)
@@ -72,25 +72,14 @@ if ($mform->is_cancelled()) {
     redirect($returnurl);
 } else if ($formdata = $mform->get_data()) {
     require_sesskey();
-
-    // Get the file and the contenttype to manage given file's extension.
-    $usercontext = context_user::instance($USER->id);
+    // Get the file and create the content based on it.
+    $usercontext = \context_user::instance($USER->id);
     $fs = get_file_storage();
     $files = $fs->get_area_files($usercontext->id, 'user', 'draft', $formdata->file, 'itemid, filepath, filename', false);
-
     if (!empty($files)) {
         $file = reset($files);
-        $filename = $file->get_filename();
-        $extension = $cb->get_extension($filename);
-        $plugin = $cb->get_extension_supporter($extension, $context);
-        $classname = '\\contenttype_'.$plugin.'\\contenttype';
-        $record = new stdClass();
-        $record->name = $filename;
-        if (class_exists($classname)) {
-            $contentype = new $classname($context);
-            $content = $contentype->create_content($record);
-            file_save_draft_area_files($formdata->file, $contextid, 'contentbank', 'public', $content->get_id());
-        }
+        $content = $cb->create_content_from_file($context, $USER->id, $file);
+        file_save_draft_area_files($formdata->file, $contextid, 'contentbank', 'public', $content->get_id());
     }
     redirect($returnurl);
 }
index ea7a72e..d9441d4 100644 (file)
Binary files a/course/amd/build/activitychooser.min.js and b/course/amd/build/activitychooser.min.js differ
index f410d70..3348e19 100644 (file)
Binary files a/course/amd/build/activitychooser.min.js.map and b/course/amd/build/activitychooser.min.js.map differ
index 0207281..d3b83d0 100644 (file)
@@ -79,7 +79,13 @@ const registerListenerEvents = (courseId) => {
         document.addEventListener(event, async(e) => {
             if (e.target.closest(selectors.elements.sectionmodchooser)) {
                 const data = await fetchModuleData();
-                const caller = e.target.closest(selectors.elements.sectionmodchooser);
+                // We need to know who called this.
+                // Standard courses use the ID in the main section info.
+                const sectionDiv = e.target.closest(selectors.elements.section);
+                // Front page courses need some special handling.
+                const button = e.target.closest(selectors.elements.sectionmodchooser);
+                // If we don't have a section ID use the fallback ID.
+                const caller = sectionDiv || button;
                 const favouriteFunction = partiallyAppliedFavouriteManager(data, caller.dataset.sectionid);
                 const builtModuleData = sectionIdMapper(data, caller.dataset.sectionid);
                 const sectionModal = await modalBuilder(builtModuleData);
index f42fa95..6fbe141 100644 (file)
@@ -198,9 +198,13 @@ abstract class format_section_renderer_base extends plugin_renderer_base {
             }
         }
 
-        $o.= html_writer::start_tag('li', array('id' => 'section-'.$section->section,
-            'class' => 'section main clearfix'.$sectionstyle, 'role'=>'region',
-            'aria-labelledby' => "sectionid-{$section->id}-title"));
+        $o .= html_writer::start_tag('li', [
+            'id' => 'section-'.$section->section,
+            'class' => 'section main clearfix'.$sectionstyle,
+            'role' => 'region',
+            'aria-labelledby' => "sectionid-{$section->id}-title",
+            'data-sectionid' => $section->section
+        ]);
 
         $leftcontent = $this->section_left_content($section, $course, $onsectionpage);
         $o.= html_writer::tag('div', $leftcontent, array('class' => 'left side'));
@@ -399,8 +403,13 @@ abstract class format_section_renderer_base extends plugin_renderer_base {
 
         $title = get_section_name($course, $section);
         $o = '';
-        $o .= html_writer::start_tag('li', array('id' => 'section-'.$section->section,
-            'class' => $classattr, 'role'=>'region', 'aria-label'=> $title));
+        $o .= html_writer::start_tag('li', [
+            'id' => 'section-'.$section->section,
+            'class' => $classattr,
+            'role' => 'region',
+            'aria-label' => $title,
+            'data-sectionid' => $section->section
+        ]);
 
         $o .= html_writer::tag('div', '', array('class' => 'left side'));
         $o .= html_writer::tag('div', '', array('class' => 'right side'));
@@ -643,14 +652,22 @@ abstract class format_section_renderer_base extends plugin_renderer_base {
      */
     protected function stealth_section_header($sectionno) {
         $o = '';
-        $o.= html_writer::start_tag('li', array('id' => 'section-'.$sectionno, 'class' => 'section main clearfix orphaned hidden'));
-        $o.= html_writer::tag('div', '', array('class' => 'left side'));
+        $o .= html_writer::start_tag('li', [
+            'id' => 'section-'.$sectionno,
+            'class' => 'section main clearfix orphaned hidden',
+            'data-sectionid' => $sectionno
+        ]);
+        $o .= html_writer::tag('div', '', array('class' => 'left side'));
         $course = course_get_format($this->page->course)->get_course();
         $section = course_get_format($this->page->course)->get_section($sectionno);
         $rightcontent = $this->section_right_content($section, $course, false);
-        $o.= html_writer::tag('div', $rightcontent, array('class' => 'right side'));
-        $o.= html_writer::start_tag('div', array('class' => 'content'));
-        $o.= $this->output->heading(get_string('orphanedactivitiesinsectionno', '', $sectionno), 3, 'sectionname');
+        $o .= html_writer::tag('div', $rightcontent, array('class' => 'right side'));
+        $o .= html_writer::start_tag('div', array('class' => 'content'));
+        $o .= $this->output->heading(
+            get_string('orphanedactivitiesinsectionno', '', $sectionno),
+            3,
+            'sectionname'
+        );
         return $o;
     }
 
@@ -681,7 +698,11 @@ abstract class format_section_renderer_base extends plugin_renderer_base {
         }
 
         $o = '';
-        $o.= html_writer::start_tag('li', array('id' => 'section-'.$sectionno, 'class' => 'section main clearfix hidden'));
+        $o .= html_writer::start_tag('li', [
+            'id' => 'section-'.$sectionno,
+            'class' => 'section main clearfix hidden',
+            'data-sectionid' => $sectionno
+        ]);
         $o.= html_writer::tag('div', '', array('class' => 'left side'));
         $o.= html_writer::tag('div', '', array('class' => 'right side'));
         $o.= html_writer::start_tag('div', array('class' => 'content'));
diff --git a/course/format/topics/styles.css b/course/format/topics/styles.css
deleted file mode 100644 (file)
index 319e883..0000000
+++ /dev/null
@@ -1,34 +0,0 @@
-.course-content ul.topics {
-    margin: 0;
-    list-style: none;
-}
-
-.course-content ul.topics li.section .content {
-    margin: 0 40px;
-}
-
-.course-content ul.topics li.section .left,
-.course-content ul.topics li.section .right {
-    width: 40px;
-    padding: 0 6px;
-}
-
-.course-content ul.topics li.section .left {
-    padding-top: 22px;
-    text-align: right;
-}
-
-.jsenabled .course-content ul.topics li.section .left,
-.jsenabled .course-content ul.topics li.section .right {
-    width: auto;
-}
-
-.course-content ul.topics li.section .left .section-handle img.icon {
-    padding: 0;
-    vertical-align: baseline;
-}
-
-.course-content ul.topics li.section .section_action_menu .textmenu,
-.course-content ul.topics li.section .section_action_menu .menu-action-text {
-    white-space: nowrap;
-}
\ No newline at end of file
diff --git a/course/format/weeks/styles.css b/course/format/weeks/styles.css
deleted file mode 100644 (file)
index eee188f..0000000
+++ /dev/null
@@ -1,34 +0,0 @@
-.course-content ul.weeks {
-    margin: 0;
-    list-style: none;
-}
-
-.course-content ul.weeks li.section .content {
-    margin: 0 40px;
-}
-
-.course-content ul.weeks li.section .left,
-.course-content ul.weeks li.section .right {
-    width: 40px;
-    padding: 0 6px;
-}
-
-.course-content ul.weeks li.section .left {
-    padding-top: 22px;
-    text-align: right;
-}
-
-.jsenabled .course-content ul.weeks li.section .left,
-.jsenabled .course-content ul.weeks li.section .right {
-    width: auto;
-}
-
-.course-content ul.weeks li.section .left .section-handle img.icon {
-    padding: 0;
-    vertical-align: baseline;
-}
-
-.course-content ul.weeks li.section .section_action_menu .textmenu,
-.course-content ul.weeks li.section .section_action_menu .menu-action-text {
-    white-space: nowrap;
-}
\ No newline at end of file
index 9738ac4..13e0207 100644 (file)
@@ -208,13 +208,14 @@ if (!empty($activities)) {
 
         if (($activity->type == 'section') && ($param->sortby == 'default')) {
             if ($inbox) {
+                echo html_writer::end_tag('ul');
                 echo $OUTPUT->box_end();
-                echo $OUTPUT->spacer(array('height'=>30, 'br'=>true)); // should be done with CSS instead
             }
             echo $OUTPUT->box_start();
             if (strval($activity->name) !== '') {
-                echo html_writer::tag('h2', $activity->name);
+                echo html_writer::tag('h3', $activity->name, ['class' => 'h5']);
             }
+            echo html_writer::start_tag('ul', ['class' => 'list-unstyled']);
             $inbox = true;
 
         } else if ($activity->type == 'activity') {
@@ -233,7 +234,7 @@ if (!empty($activities)) {
                 $image = $OUTPUT->pix_icon('icon', $modfullname, $cm->modname, array('class' => 'icon smallicon'));
                 $link = html_writer::link(new moodle_url("/mod/$cm->modname/view.php",
                             array("id" => $cm->id)), $name, array('class' => $class));
-                echo html_writer::tag('h3', "$image $modfullname $link");
+                echo html_writer::tag('li', "$image $modfullname $link");
            }
 
         } else {
@@ -251,7 +252,9 @@ if (!empty($activities)) {
             $print_recent_mod_activity = $activity->type.'_print_recent_mod_activity';
 
             if (function_exists($print_recent_mod_activity)) {
+                echo html_writer::start_tag('li');
                 $print_recent_mod_activity($activity, $course->id, $detail, $modnames, $viewfullnames[$activity->cmid]);
+                echo html_writer::end_tag('li');
             }
         }
     }
index 6f1a956..8d58bc8 100644 (file)
@@ -368,11 +368,11 @@ class core_course_renderer extends plugin_renderer_base {
             $ajaxcontrol .= html_writer::start_tag('div', array('class' => 'section-modchooser'));
             $icon = $this->output->pix_icon('t/add', '');
             $span = html_writer::tag('span', $straddeither, array('class' => 'section-modchooser-text'));
-            $ajaxcontrol .= html_writer::tag('button', $icon . $span, array(
+            $ajaxcontrol .= html_writer::tag('button', $icon . $span, [
                     'class' => 'section-modchooser-link btn btn-link',
                     'data-action' => 'open-chooser',
                     'data-sectionid' => $section,
-                )
+                ]
             );
             $ajaxcontrol .= html_writer::end_tag('div');
             $ajaxcontrol .= html_writer::end_tag('div');
index dc8e8f1..144d5ef 100644 (file)
Binary files a/course/yui/build/moodle-course-dragdrop/moodle-course-dragdrop-debug.js and b/course/yui/build/moodle-course-dragdrop/moodle-course-dragdrop-debug.js differ
index 5782dd8..04e39ef 100644 (file)
Binary files a/course/yui/build/moodle-course-dragdrop/moodle-course-dragdrop-min.js and b/course/yui/build/moodle-course-dragdrop/moodle-course-dragdrop-min.js differ
index 5d985c2..69533d9 100644 (file)
Binary files a/course/yui/build/moodle-course-dragdrop/moodle-course-dragdrop.js and b/course/yui/build/moodle-course-dragdrop/moodle-course-dragdrop.js differ
index ac80e63..55496a9 100644 (file)
@@ -254,6 +254,8 @@ Y.extend(DRAGSECTION, M.core.dragdrop, {
                                 // Update flag.
                                 swapped = true;
                             }
+                            sectionlist.item(index).setAttribute('data-sectionid',
+                                Y.Moodle.core_course.util.section.getId(sectionlist.item(index)));
                         }
                         loopend = loopend - 1;
                     } while (swapped);
index f5dde91..28c7504 100644 (file)
@@ -111,9 +111,9 @@ if ($ADMIN->fulltree) {
 
     $settings->add(new admin_setting_configtext('enrol_database/newcoursecategory', get_string('newcoursecategory', 'enrol_database'), '', ''));
 
-    require_once($CFG->dirroot.'/enrol/database/settingslib.php');
-
-    $settings->add(new enrol_database_admin_setting_category('enrol_database/defaultcategory', get_string('defaultcategory', 'enrol_database'), get_string('defaultcategory_desc', 'enrol_database')));
+    $settings->add(new admin_settings_coursecat_select('enrol_database/defaultcategory',
+        get_string('defaultcategory', 'enrol_database'),
+        get_string('defaultcategory_desc', 'enrol_database'), 1));
 
     $settings->add(new admin_setting_configtext('enrol_database/templatecourse', get_string('templatecourse', 'enrol_database'), get_string('templatecourse_desc', 'enrol_database'), ''));
 }
diff --git a/enrol/database/settingslib.php b/enrol/database/settingslib.php
deleted file mode 100644 (file)
index a1bace8..0000000
+++ /dev/null
@@ -1,46 +0,0 @@
-<?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/>.
-
-/**
- * Database enrolment plugin custom settings.
- *
- * @package    enrol_database
- * @copyright  2013 Darko Miletic
- * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
- */
-
-defined('MOODLE_INTERNAL') || die();
-
-/**
- * Class implements new specialized setting for course categories that are loaded
- * only when required
- * @author Darko Miletic
- *
- */
-class enrol_database_admin_setting_category extends admin_setting_configselect {
-    public function __construct($name, $visiblename, $description) {
-        parent::__construct($name, $visiblename, $description, 1, null);
-    }
-
-    public function load_choices() {
-        if (is_array($this->choices)) {
-            return true;
-        }
-
-        $this->choices = make_categories_options();
-        return true;
-    }
-}
index 8fccd35..53c0931 100644 (file)
@@ -1,4 +1,8 @@
 This files describes API changes in the enrol_database code.
 
+=== 3.9 ===
+* Class enrol_database_admin_setting_category has been removed. This class was only used by the database
+  enrolment plugin settings and it was replaced by admin_settings_coursecat_select.
+
 === 3.7 ===
 * enrol/database/cli/sync.php script has been deprecated in favour of enrol_database\task\sync_enrolments task.
index 9b64471..3e83f5a 100644 (file)
@@ -43,5 +43,17 @@ function xmldb_filter_mathjaxloader_upgrade($oldversion) {
     // Automatically generated Moodle v3.8.0 release upgrade line.
     // Put any upgrade step following this.
 
+    if ($oldversion < 2020050401) {
+        // Update CDN url.
+        $originalurl = 'https://cdnjs.cloudflare.com/ajax/libs/mathjax/2.7.2/MathJax.js';
+        $newurl = 'https://cdn.jsdelivr.net/npm/mathjax@2.7.8/MathJax.js';
+        $currenturl = get_config('filter_mathjaxloader', 'httpsurl');
+        if ($currenturl == $originalurl) {
+            set_config('httpsurl', $newurl, 'filter_mathjaxloader');
+        }
+
+        upgrade_plugin_savepoint(true, 2020050401, 'filter', 'mathjaxloader');
+    }
+
     return true;
 }
index 055a986..32a4fc0 100644 (file)
@@ -1,7 +1,7 @@
 Description of MathJAX library integration in Moodle
 ====================================================
 
-* Default MathJax version: 2.7.2
+* Default MathJax version: 2.7.8
 * License: Apache 2.0
 * Source: https://www.mathjax.org/
 
@@ -18,3 +18,8 @@ Upgrading the default MathJax version
 3. Check and eventually update the list of language mappings in filter.php.
    Also see the unit test for the language mappings.
 
+Changes
+-------
+
+* The MathJax 2.7.2 seems to have a possible security issue, the CDN default value have been
+updated to point to the recommended 2.7.8 version. See MDL-68430 for details.
index a911788..06ceee8 100644 (file)
@@ -33,7 +33,7 @@ if ($ADMIN->fulltree) {
     $item = new admin_setting_configtext('filter_mathjaxloader/httpsurl',
                                          new lang_string('httpsurl', 'filter_mathjaxloader'),
                                          new lang_string('httpsurl_help', 'filter_mathjaxloader'),
-                                         'https://cdnjs.cloudflare.com/ajax/libs/mathjax/2.7.2/MathJax.js',
+                                         'https://cdn.jsdelivr.net/npm/mathjax@2.7.8/MathJax.js',
                                          PARAM_RAW);
     $settings->add($item);
 
index 6659239..774eac9 100644 (file)
@@ -24,6 +24,6 @@
 
 defined('MOODLE_INTERNAL') || die();
 
-$plugin->version  = 2019111800;
+$plugin->version  = 2020050401;
 $plugin->requires = 2019111200;  // Requires this Moodle version.
 $plugin->component= 'filter_mathjaxloader';
index 319ef65..41a2598 100644 (file)
@@ -26,6 +26,7 @@ defined('MOODLE_INTERNAL') || die();
 
 $string['addcomment'] = 'Add frequently used comment';
 $string['additionalcomments'] = 'Additional comments';
+$string['additionalcommentsforcriterion'] = 'Additional comments for criterion, {$a}';
 $string['addcriterion'] = 'Add criterion';
 $string['alwaysshowdefinition'] = 'Show guide definition to students';
 $string['backtoediting'] = 'Back to editing';
@@ -33,6 +34,7 @@ $string['clicktocopy'] = 'Click to copy this text into the criteria feedback';
 $string['clicktoedit'] = 'Click to edit';
 $string['clicktoeditname'] = 'Click to edit criterion name';
 $string['comment'] = 'Comment';
+$string['commentpickerforcriterion'] = 'Frequently used comments picker for {$a} additional comments';
 $string['comments'] = 'Frequently used comments';
 $string['commentsdelete'] = 'Delete comment';
 $string['commentsempty'] = 'Click to edit comment';
@@ -70,6 +72,7 @@ $string['guideoptions'] = 'Marking guide options';
 $string['guidestatus'] = 'Current marking guide status';
 $string['hidemarkerdesc'] = 'Hide marker criterion descriptions';
 $string['hidestudentdesc'] = 'Hide student criterion descriptions';
+$string['informationforcriterion'] = '{$a} information';
 $string['insertcomment'] = 'Insert frequently used comment';
 $string['maxscore'] = 'Maximum score';
 $string['name'] = 'Name';
@@ -88,11 +91,14 @@ $string['regrademessage1'] = 'You are about to save changes to a marking guide t
 $string['regrademessage5'] = 'You are about to save significant changes to a marking guide that has already been used for grading. The gradebook value will be unchanged, but the marking guide will be hidden from students until their item is regraded.';
 $string['regradeoption0'] = 'Do not mark for regrade';
 $string['regradeoption1'] = 'Mark for regrade';
+$string['remark_help'] = 'Enter any additional comments about this criterion.';
 $string['restoredfromdraft'] = 'NOTE: The last attempt to grade this person was not saved properly so draft grades have been restored. If you want to cancel these changes use the \'Cancel\' button below.';
 $string['save'] = 'Save';
 $string['saveguide'] = 'Save marking guide and make it ready';
 $string['saveguidedraft'] = 'Save as draft';
 $string['score'] = 'score';
+$string['scoreforcriterion'] = '{$a} score';
+$string['score_help'] = 'Enter a score for {$a->criterion} between 0 and {$a->maxscore}.';
 $string['showmarkerdesc'] = 'Show marker criterion descriptions';
 $string['showmarkspercriterionstudents'] = 'Show marks per criterion to students';
 $string['showstudentdesc'] = 'Show student criterion descriptions';
index 8b5fc97..965c805 100644 (file)
@@ -73,6 +73,7 @@
             type="button"
           >
             {{# pix }} info, gradingform_guide {{/ pix }}
+            <span class="sr-only">{{#str}}informationforcriterion, gradingform_guide, {{name}}{{/str}}</span>
         </button>
         <button class="criterion-toggle btn btn-icon icon-no-margin text-reset p-0 font-weight-bold mb-0 ml-auto"
                 type="button"
         <div class="form-group">
           <label for="gradingform_guide-{{uniqid}}-criteria-{{id}}-score">{{#str}}outof, gradingform_guide, {{maxscore}}{{/str}}</label>
           <input class="form-control" type="number" name="advancedgrading[criteria][{{id}}][score]" value="{{score}}"
-              id="gradingform_guide-{{uniqid}}-criteria-{{id}}-score"
-              aria-describedby="gradingform_guide-{{uniqid}}-help-{{id}}-score">
-          <small id="gradingform_guide-{{uniqid}}-help-{{id}}-score" class="sr-only">{{#str}}grade_help, gradingform_guide{{/str}}</small>
+            id="gradingform_guide-{{uniqid}}-criteria-{{id}}-score"
+            aria-describedby="gradingform_guide-{{uniqid}}-help-{{id}}-score"
+            min="0" max="{{maxscore}}"
+            aria-label="{{#str}}scoreforcriterion, gradingform_guide, {{name}}{{/str}}">
+          <span id="gradingform_guide-{{uniqid}}-help-{{id}}-score" aria-hidden="true" class="sr-only">{{!
+            }}{{#str}}score_help, gradingform_guide, { "criterion":  {{# quote }}{{ name }}{{/ quote }}, "maxscore": {{# quote }}{{ maxscore }}{{/ quote }} }{{/str}}
+          </span>
         </div>
         <div class="form-group ">
-          <label class="text-muted" for="gradingform_guide-{{uniqid}}-criteria-{{id}}-remark">{{#str}}additionalcomments, gradingform_guide{{/str}}</label>
+          <label for="gradingform_guide-{{uniqid}}-criteria-{{id}}-remark">{{#str}}additionalcomments, gradingform_guide{{/str}}</label>
           <div class="input-group mb-3 form-inset form-inset-right">
             <textarea class="form-control" type="text" name="advancedgrading[criteria][{{id}}][remark]"
                 id="gradingform_guide-{{uniqid}}-criteria-{{id}}-remark"
                 aria-describedby="gradingform_guide-{{uniqid}}-help-{{id}}-remark"
+                aria-label="{{#str}}additionalcommentsforcriterion, gradingform_guide, {{name}}{{/str}}"
                 data-gradingform-guide-role="remark"
                 rows="2"
                 data-max-rows="5"
                 type="button"
               >
                   {{#pix}}plus, gradingform_guide{{/pix}}
+                  <span class="sr-only">{{#str}}commentpickerforcriterion, gradingform_guide, {{name}}{{/str}}</span>
               </button>
             {{/hascomments}}
           </div>
               </div>
             </div>
           {{/hascomments}}
-          <small id="gradingform_guide-{{uniqid}}-help-{{id}}-remark" class="sr-only">{{#str}}grade_help, gradingform_guide{{/str}}</small>
+          <span id="gradingform_guide-{{uniqid}}-help-{{id}}-remark" aria-hidden="true" class="sr-only">{{#str}}remark_help, gradingform_guide{{/str}}</span>
         </div>
       </div>
     </div>
index 9370d23..31bcd05 100644 (file)
@@ -858,8 +858,9 @@ $string['nosupportedlogstore'] = 'No supported logstore found';
 $string['maxtimelimit'] = 'Maximum time limit';
 $string['maxtimelimit_desc'] = 'To restrict the maximum PHP execution time that Moodle will allow without any output being displayed, enter a value in seconds here. 0 means that Moodle default restrictions are used. If you have a front-end server with its own time limit, set this value lower to receive PHP errors in logs. Does not apply to CLI scripts.';
 $string['moodleapp'] = 'Moodle app';
-$string['moodleapp_help'] = '<p>If you have an account on a Moodle site, you can access all your courses on your mobile devices with our free mobile app for Moodle.</p>
-<a href="https://moodle.com/mobile-app">Get the Moodle app</a>';
+$string['moodleapp_help'] = '<p>The free Moodle app enables users to access their courses on mobile devices. Additional app features are available with a Pro or Premium app plan.</p>
+<a href="https://moodle.com/mobile-app">Get the Moodle app</a><br />
+<a href="https://apps.moodle.com">Moodle Apps Portal</a>';
 $string['moodlebrandedapp'] = 'Branded Moodle app';
 $string['moodlebrandedapp_help'] = '<p>The Branded Moodle app has all the functionality of our free mobile app for Moodle combined with your own custom branding.</p>
 <a href="https://moodle.com/branded-app">About the Branded Moodle app</a>';
index 1d3b885..06958d6 100644 (file)
@@ -28,6 +28,11 @@ $string['contentname'] = 'Content name';
 $string['contentnotdeleted'] = 'An error was encountered while trying to delete the content.';
 $string['contentnotrenamed'] = 'An error was encountered while trying to rename the content.';
 $string['contentrenamed'] = 'The content has been renamed.';
+$string['eventcontentcreated'] = 'Content created';
+$string['eventcontentdeleted'] = 'Content deleted';
+$string['eventcontentupdated'] = 'Content updated';
+$string['eventcontentuploaded'] = 'Content uploaded';
+$string['eventcontentviewed'] = 'Content viewed';
 $string['deletecontent'] = 'Delete content';
 $string['deletecontentconfirm'] = 'Are you sure you want to delete the content <em>\'{$a->name}\'</em> and all associated files? This action cannot be undone.';
 $string['file'] = 'Upload content';
index 6c419f2..1e56b09 100644 (file)
@@ -776,7 +776,7 @@ function has_capability_in_accessdata($capability, context $context, array &$acc
     // Build $paths as a list of current + all parent "paths" with order bottom-to-top
     $path = $context->path;
     $paths = array($path);
-    while($path = rtrim($path, '0123456789')) {
+    while ($path = rtrim($path, '0123456789')) {
         $path = rtrim($path, '/');
         if ($path === '') {
             break;
@@ -1106,7 +1106,7 @@ function remove_temp_course_roles(context_course $coursecontext) {
     $ras = $DB->get_records_sql($sql, array('contextid'=>$coursecontext->id, 'userid'=>$USER->id));
 
     $USER->access['ra'][$coursecontext->path] = array();
-    foreach($ras as $r) {
+    foreach ($ras as $r) {
         $USER->access['ra'][$coursecontext->path][(int)$r->id] = (int)$r->id;
     }
 }
@@ -1663,7 +1663,7 @@ function role_unassign_all(array $params, $subcontexts = false, $includemanual =
     }
 
     $ras = $DB->get_records('role_assignments', $params);
-    foreach($ras as $ra) {
+    foreach ($ras as $ra) {
         $DB->delete_records('role_assignments', array('id'=>$ra->id));
         if ($context = context::instance_by_id($ra->contextid, IGNORE_MISSING)) {
             // Role assignments have changed, so mark user as dirty.
@@ -1697,10 +1697,10 @@ function role_unassign_all(array $params, $subcontexts = false, $includemanual =
         if ($context) {
             $contexts = $context->get_child_contexts();
             $mparams = $params;
-            foreach($contexts as $context) {
+            foreach ($contexts as $context) {
                 $mparams['contextid'] = $context->id;
                 $ras = $DB->get_records('role_assignments', $mparams);
-                foreach($ras as $ra) {
+                foreach ($ras as $ra) {
                     $DB->delete_records('role_assignments', array('id'=>$ra->id));
                     // Role assignments have changed, so mark user as dirty.
                     mark_user_dirty($ra->userid);
@@ -1978,7 +1978,7 @@ function can_access_course(stdClass $course, $user = null, $withcapability = '',
     // if not enrolled try to gain temporary guest access
     $instances = $DB->get_records('enrol', array('courseid'=>$course->id, 'status'=>ENROL_INSTANCE_ENABLED), 'sortorder, id ASC');
     $enrols = enrol_get_plugins(true);
-    foreach($instances as $instance) {
+    foreach ($instances as $instance) {
         if (!isset($enrols[$instance->enrol])) {
             continue;
         }
@@ -2067,7 +2067,7 @@ function get_default_capabilities($archetype) {
             $alldefs = array_merge($alldefs, load_capability_def($cap['component']));
         }
     }
-    foreach($alldefs as $name=>$def) {
+    foreach ($alldefs as $name=>$def) {
         // Use array 'archetypes if available. Only if not specified, use 'legacy'.
         if (isset($def['archetypes'])) {
             if (isset($def['archetypes'][$archetype])) {
@@ -2188,7 +2188,7 @@ function reset_role_capabilities($roleid) {
     $DB->delete_records('role_capabilities',
             array('roleid' => $roleid, 'contextid' => $systemcontext->id));
 
-    foreach($defaultcaps as $cap=>$permission) {
+    foreach ($defaultcaps as $cap=>$permission) {
         assign_capability($cap, $permission, $roleid, $systemcontext->id);
     }
 
@@ -2215,7 +2215,7 @@ function update_capabilities($component = 'moodle') {
     $storedcaps = array();
 
     $filecaps = load_capability_def($component);
-    foreach($filecaps as $capname=>$unused) {
+    foreach ($filecaps as $capname=>$unused) {
         if (!preg_match('|^[a-z]+/[a-z_0-9]+:[a-z_0-9]+$|', $capname)) {
             debugging("Coding problem: Invalid capability name '$capname', use 'clonepermissionsfrom' field for migration.");
         }
@@ -2345,7 +2345,7 @@ function capabilities_cleanup($component, $newcapdef = null) {
 
                 // Delete from roles.
                 if ($roles = get_roles_with_capability($cachedcap->name)) {
-                    foreach($roles as $role) {
+                    foreach ($roles as $role) {
                         if (!unassign_capability($cachedcap->name, $role->id)) {
                             print_error('cannotunassigncap', 'error', '', (object)array('cap'=>$cachedcap->name, 'role'=>$role->name));
                         }
@@ -3427,36 +3427,25 @@ function set_role_contextlevels($roleid, array $contextlevels) {
 }
 
 /**
- * Who has this capability in this context?
- *
- * This can be a very expensive call - use sparingly and keep
- * the results if you are going to need them again soon.
- *
- * Note if $fields is empty this function attempts to get u.*
- * which can get rather large - and has a serious perf impact
- * on some DBs.
+ * Gets sql joins for finding users with capability in the given context.
  *
- * @param context $context
- * @param string|array $capability - capability name(s)
- * @param string $fields - fields to be pulled. The user table is aliased to 'u'. u.id MUST be included.
- * @param string $sort - the sort order. Default is lastaccess time.
- * @param mixed $limitfrom - number of records to skip (offset)
- * @param mixed $limitnum - number of records to fetch
- * @param string|array $groups - single group or array of groups - only return
- *               users who are in one of these group(s).
- * @param string|array $exceptions - list of users to exclude, comma separated or array
- * @param bool $doanything_ignored not used any more, admin accounts are never returned
- * @param bool $view_ignored - use get_enrolled_sql() instead
- * @param bool $useviewallgroups if $groups is set the return users who
- *               have capability both $capability and moodle/site:accessallgroups
- *               in this context, as well as users who have $capability and who are
- *               in $groups.
- * @return array of user records
+ * @param context $context Context for the join.
+ * @param string|array $capability Capability name or array of names.
+ *      If an array is provided then this is the equivalent of a logical 'OR',
+ *      i.e. the user needs to have one of these capabilities.
+ * @param string $useridcolumn e.g. 'u.id'.
+ * @return \core\dml\sql_join Contains joins, wheres, params.
+ *      This function will set ->cannotmatchanyrows if applicable.
+ *      This may let you skip doing a DB query.
  */
-function get_users_by_capability(context $context, $capability, $fields = '', $sort = '', $limitfrom = '', $limitnum = '',
-                                 $groups = '', $exceptions = '', $doanything_ignored = null, $view_ignored = null, $useviewallgroups = false) {
+function get_with_capability_join(context $context, $capability, $useridcolumn) {
     global $CFG, $DB;
 
+    // Add a unique prefix to param names to ensure they are unique.
+    static $i = 0;
+    $i++;
+    $paramprefix = 'eu' . $i . '_';
+
     $defaultuserroleid      = isset($CFG->defaultuserroleid) ? $CFG->defaultuserroleid : 0;
     $defaultfrontpageroleid = isset($CFG->defaultfrontpageroleid) ? $CFG->defaultfrontpageroleid : 0;
 
@@ -3464,26 +3453,18 @@ function get_users_by_capability(context $context, $capability, $fields = '', $s
     $ctxids = str_replace('/', ',', $ctxids);
 
     // Context is the frontpage
-    $iscoursepage = false; // coursepage other than fp
-    $isfrontpage = false;
-    if ($context->contextlevel == CONTEXT_COURSE) {
-        if ($context->instanceid == SITEID) {
-            $isfrontpage = true;
-        } else {
-            $iscoursepage = true;
-        }
-    }
-    $isfrontpage = ($isfrontpage || is_inside_frontpage($context));
+    $isfrontpage = $context->contextlevel == CONTEXT_COURSE && $context->instanceid == SITEID;
+    $isfrontpage = $isfrontpage || is_inside_frontpage($context);
 
-    $caps = (array)$capability;
+    $caps = (array) $capability;
 
-    // construct list of context paths bottom-->top
+    // Construct list of context paths bottom --> top.
     list($contextids, $paths) = get_context_info_list($context);
 
-    // we need to find out all roles that have these capabilities either in definition or in overrides
-    $defs = array();
-    list($incontexts, $params) = $DB->get_in_or_equal($contextids, SQL_PARAMS_NAMED, 'con');
-    list($incaps, $params2) = $DB->get_in_or_equal($caps, SQL_PARAMS_NAMED, 'cap');
+    // We need to find out all roles that have these capabilities either in definition or in overrides.
+    $defs = [];
+    list($incontexts, $params) = $DB->get_in_or_equal($contextids, SQL_PARAMS_NAMED, $paramprefix . 'con');
+    list($incaps, $params2) = $DB->get_in_or_equal($caps, SQL_PARAMS_NAMED, $paramprefix . 'cap');
 
     // Check whether context locking is enabled.
     // Filter out any write capability if this is the case.
@@ -3507,15 +3488,15 @@ function get_users_by_capability(context $context, $capability, $fields = '', $s
         $defs[$rc->capability][$rc->path][$rc->roleid] = $rc->permission;
     }
 
-    // go through the permissions bottom-->top direction to evaluate the current permission,
-    // first one wins (prohibit is an exception that always wins)
-    $access = array();
+    // Go through the permissions bottom-->top direction to evaluate the current permission,
+    // first one wins (prohibit is an exception that always wins).
+    $access = [];
     foreach ($caps as $cap) {
         foreach ($paths as $path) {
             if (empty($defs[$cap][$path])) {
                 continue;
             }
-            foreach($defs[$cap][$path] as $roleid => $perm) {
+            foreach ($defs[$cap][$path] as $roleid => $perm) {
                 if ($perm == CAP_PROHIBIT) {
                     $access[$cap][$roleid] = CAP_PROHIBIT;
                     continue;
@@ -3527,9 +3508,9 @@ function get_users_by_capability(context $context, $capability, $fields = '', $s
         }
     }
 
-    // make lists of roles that are needed and prohibited in this context
-    $needed = array(); // one of these is enough
-    $prohibited = array(); // must not have any of these
+    // Make lists of roles that are needed and prohibited in this context.
+    $needed = []; // One of these is enough.
+    $prohibited = []; // Must not have any of these.
     foreach ($caps as $cap) {
         if (empty($access[$cap])) {
             continue;
@@ -3543,11 +3524,11 @@ function get_users_by_capability(context $context, $capability, $fields = '', $s
             }
         }
         if (empty($needed[$cap]) or !empty($prohibited[$cap][$defaultuserroleid])) {
-            // easy, nobody has the permission
+            // Easy, nobody has the permission.
             unset($needed[$cap]);
             unset($prohibited[$cap]);
         } else if ($isfrontpage and !empty($prohibited[$cap][$defaultfrontpageroleid])) {
-            // everybody is disqualified on the frontpage
+            // Everybody is disqualified on the frontpage.
             unset($needed[$cap]);
             unset($prohibited[$cap]);
         }
@@ -3557,23 +3538,145 @@ function get_users_by_capability(context $context, $capability, $fields = '', $s
     }
 
     if (empty($needed)) {
-        // there can not be anybody if no roles match this request
-        return array();
+        // There can not be anybody if no roles match this request.
+        return new \core\dml\sql_join('', '1 = 2', [], true);
     }
 
     if (empty($prohibited)) {
-        // we can compact the needed roles
-        $n = array();
+        // We can compact the needed roles.
+        $n = [];
         foreach ($needed as $cap) {
-            foreach ($cap as $roleid=>$unused) {
+            foreach ($cap as $roleid => $unused) {
                 $n[$roleid] = true;
             }
         }
-        $needed = array('any'=>$n);
+        $needed = ['any' => $n];
         unset($n);
     }
 
-    // ***** Set up default fields ******
+    // Prepare query clauses.
+    $wherecond = [];
+    $params    = [];
+    $joins     = [];
+    $cannotmatchanyrows = false;
+
+    // We never return deleted users or guest account.
+    // Use a hack to get the deleted user column without an API change.
+    $deletedusercolumn = substr($useridcolumn, 0, -2) . 'deleted';
+    $wherecond[] = "$deletedusercolumn = 0 AND $useridcolumn <> :{$paramprefix}guestid";
+    $params[$paramprefix . 'guestid'] = $CFG->siteguest;
+
+    // Now add the needed and prohibited roles conditions as joins.
+    if (!empty($needed['any'])) {
+        // Simple case - there are no prohibits involved.
+        if (!empty($needed['any'][$defaultuserroleid]) ||
+                ($isfrontpage && !empty($needed['any'][$defaultfrontpageroleid]))) {
+            // Everybody.
+        } else {
+            $joins[] = "JOIN (SELECT DISTINCT userid
+                                FROM {role_assignments}
+                               WHERE contextid IN ($ctxids)
+                                     AND roleid IN (" . implode(',', array_keys($needed['any'])) . ")
+                             ) ra ON ra.userid = $useridcolumn";
+        }
+    } else {
+        $unions = [];
+        $everybody = false;
+        foreach ($needed as $cap => $unused) {
+            if (empty($prohibited[$cap])) {
+                if (!empty($needed[$cap][$defaultuserroleid]) ||
+                        ($isfrontpage && !empty($needed[$cap][$defaultfrontpageroleid]))) {
+                    $everybody = true;
+                    break;
+                } else {
+                    $unions[] = "SELECT userid
+                                   FROM {role_assignments}
+                                  WHERE contextid IN ($ctxids)
+                                        AND roleid IN (".implode(',', array_keys($needed[$cap])) .")";
+                }
+            } else {
+                if (!empty($prohibited[$cap][$defaultuserroleid]) ||
+                        ($isfrontpage && !empty($prohibited[$cap][$defaultfrontpageroleid]))) {
+                    // Nobody can have this cap because it is prohibited in default roles.
+                    continue;
+
+                } else if (!empty($needed[$cap][$defaultuserroleid]) ||
+                        ($isfrontpage && !empty($needed[$cap][$defaultfrontpageroleid]))) {
+                    // Everybody except the prohibited - hiding does not matter.
+                    $unions[] = "SELECT id AS userid
+                                   FROM {user}
+                                  WHERE id NOT IN (SELECT userid
+                                                     FROM {role_assignments}
+                                                    WHERE contextid IN ($ctxids)
+                                                          AND roleid IN (" . implode(',', array_keys($prohibited[$cap])) . "))";
+
+                } else {
+                    $unions[] = "SELECT userid
+                                   FROM {role_assignments}
+                                  WHERE contextid IN ($ctxids) AND roleid IN (" . implode(',', array_keys($needed[$cap])) . ")
+                                        AND userid NOT IN (
+                                            SELECT userid
+                                              FROM {role_assignments}
+                                             WHERE contextid IN ($ctxids)
+                                                   AND roleid IN (" . implode(',', array_keys($prohibited[$cap])) . "))";
+                }
+            }
+        }
+
+        if (!$everybody) {
+            if ($unions) {
+                $joins[] = "JOIN (
+                                  SELECT DISTINCT userid
+                                    FROM (
+                                            " . implode("\n UNION \n", $unions) . "
+                                         ) us
+                                 ) ra ON ra.userid = $useridcolumn";
+            } else {
+                // Only prohibits found - nobody can be matched.
+                $wherecond[] = "1 = 2";
+                $cannotmatchanyrows = true;
+            }
+        }
+    }
+
+    return new \core\dml\sql_join(implode("\n", $joins), implode(" AND ", $wherecond), $params, $cannotmatchanyrows);
+}
+
+/**
+ * Who has this capability in this context?
+ *
+ * This can be a very expensive call - use sparingly and keep
+ * the results if you are going to need them again soon.
+ *
+ * Note if $fields is empty this function attempts to get u.*
+ * which can get rather large - and has a serious perf impact
+ * on some DBs.
+ *
+ * @param context $context
+ * @param string|array $capability - capability name(s)
+ * @param string $fields - fields to be pulled. The user table is aliased to 'u'. u.id MUST be included.
+ * @param string $sort - the sort order. Default is lastaccess time.
+ * @param mixed $limitfrom - number of records to skip (offset)
+ * @param mixed $limitnum - number of records to fetch
+ * @param string|array $groups - single group or array of groups - only return
+ *               users who are in one of these group(s).
+ * @param string|array $exceptions - list of users to exclude, comma separated or array
+ * @param bool $notuseddoanything not used any more, admin accounts are never returned
+ * @param bool $notusedview - use get_enrolled_sql() instead
+ * @param bool $useviewallgroups if $groups is set the return users who
+ *               have capability both $capability and moodle/site:accessallgroups
+ *               in this context, as well as users who have $capability and who are
+ *               in $groups.
+ * @return array of user records
+ */
+function get_users_by_capability(context $context, $capability, $fields = '', $sort = '', $limitfrom = '', $limitnum = '',
+        $groups = '', $exceptions = '', $notuseddoanything = null, $notusedview = null, $useviewallgroups = false) {
+    global $CFG, $DB;
+
+    // Context is a course page other than the frontpage.
+    $iscoursepage = $context->contextlevel == CONTEXT_COURSE && $context->instanceid != SITEID;
+
+    // Set up default fields list if necessary.
     if (empty($fields)) {
         if ($iscoursepage) {
             $fields = 'u.*, ul.timeaccess AS lastaccess';
@@ -3586,7 +3689,7 @@ function get_users_by_capability(context $context, $capability, $fields = '', $s
         }
     }
 
-    // Set up default sort
+    // Set up default sort if necessary.
     if (empty($sort)) { // default to course lastaccess or just lastaccess
         if ($iscoursepage) {
             $sort = 'ul.timeaccess';
@@ -3595,14 +3698,20 @@ function get_users_by_capability(context $context, $capability, $fields = '', $s
         }
     }
 
-    // Prepare query clauses
-    $wherecond = array();
-    $params    = array();
-    $joins     = array();
+    // Get the bits of SQL relating to capabilities.
+    $sqljoin = get_with_capability_join($context, $capability, 'u.id');
+    if ($sqljoin->cannotmatchanyrows) {
+        return [];
+    }
 
-    // User lastaccess JOIN
+    // Prepare query clauses.
+    $wherecond = [$sqljoin->wheres];
+    $params    = $sqljoin->params;
+    $joins     = [$sqljoin->joins];
+
+    // Add user lastaccess JOIN, if required.
     if ((strpos($sort, 'ul.timeaccess') === false) and (strpos($fields, 'ul.timeaccess') === false)) {
-         // user_lastaccess is not required MDL-13810
+         // Here user_lastaccess is not required MDL-13810.
     } else {
         if ($iscoursepage) {
             $joins[] = "LEFT OUTER JOIN {user_lastaccess} ul ON (ul.userid = u.id AND ul.courseid = {$context->instanceid})";
@@ -3611,11 +3720,7 @@ function get_users_by_capability(context $context, $capability, $fields = '', $s
         }
     }
 
-    // We never return deleted users or guest account.
-    $wherecond[] = "u.deleted = 0 AND u.id <> :guestid";
-    $params['guestid'] = $CFG->siteguest;
-
-    // Groups
+    // Groups.
     if ($groups) {
         $groups = (array)$groups;
         list($grouptest, $grpparams) = $DB->get_in_or_equal($groups, SQL_PARAMS_NAMED, 'grp');
@@ -3636,7 +3741,7 @@ function get_users_by_capability(context $context, $capability, $fields = '', $s
         $wherecond[] = "($grouptest)";
     }
 
-    // User exceptions
+    // User exceptions.
     if (!empty($exceptions)) {
         $exceptions = (array)$exceptions;
         list($exsql, $exparams) = $DB->get_in_or_equal($exceptions, SQL_PARAMS_NAMED, 'exc', false);
@@ -3644,77 +3749,14 @@ function get_users_by_capability(context $context, $capability, $fields = '', $s
         $wherecond[] = "u.id $exsql";
     }
 
-    // now add the needed and prohibited roles conditions as joins
-    if (!empty($needed['any'])) {
-        // simple case - there are no prohibits involved
-        if (!empty($needed['any'][$defaultuserroleid]) or ($isfrontpage and !empty($needed['any'][$defaultfrontpageroleid]))) {
-            // everybody
-        } else {
-            $joins[] = "JOIN (SELECT DISTINCT userid
-                                FROM {role_assignments}
-                               WHERE contextid IN ($ctxids)
-                                     AND roleid IN (".implode(',', array_keys($needed['any'])) .")
-                             ) ra ON ra.userid = u.id";
-        }
-    } else {
-        $unions = array();
-        $everybody = false;
-        foreach ($needed as $cap=>$unused) {
-            if (empty($prohibited[$cap])) {
-                if (!empty($needed[$cap][$defaultuserroleid]) or ($isfrontpage and !empty($needed[$cap][$defaultfrontpageroleid]))) {
-                    $everybody = true;
-                    break;
-                } else {
-                    $unions[] = "SELECT userid
-                                   FROM {role_assignments}
-                                  WHERE contextid IN ($ctxids)
-                                        AND roleid IN (".implode(',', array_keys($needed[$cap])) .")";
-                }
-            } else {
-                if (!empty($prohibited[$cap][$defaultuserroleid]) or ($isfrontpage and !empty($prohibited[$cap][$defaultfrontpageroleid]))) {
-                    // nobody can have this cap because it is prevented in default roles
-                    continue;
-
-                } else if (!empty($needed[$cap][$defaultuserroleid]) or ($isfrontpage and !empty($needed[$cap][$defaultfrontpageroleid]))) {
-                    // everybody except the prohibitted - hiding does not matter
-                    $unions[] = "SELECT id AS userid
-                                   FROM {user}
-                                  WHERE id NOT IN (SELECT userid
-                                                     FROM {role_assignments}
-                                                    WHERE contextid IN ($ctxids)
-                                                          AND roleid IN (".implode(',', array_keys($prohibited[$cap])) ."))";
-
-                } else {
-                    $unions[] = "SELECT userid
-                                   FROM {role_assignments}
-                                  WHERE contextid IN ($ctxids) AND roleid IN (".implode(',', array_keys($needed[$cap])) .")
-                                        AND userid NOT IN (
-                                            SELECT userid
-                                              FROM {role_assignments}
-                                             WHERE contextid IN ($ctxids)
-                                                    AND roleid IN (" . implode(',', array_keys($prohibited[$cap])) . ")
-                                                        )";
-                }
-            }
-        }
-        if (!$everybody) {
-            if ($unions) {
-                $joins[] = "JOIN (SELECT DISTINCT userid FROM ( ".implode(' UNION ', $unions)." ) us) ra ON ra.userid = u.id";
-            } else {
-                // only prohibits found - nobody can be matched
-                $wherecond[] = "1 = 2";
-            }
-        }
-    }
-
-    // Collect WHERE conditions and needed joins
+    // Collect WHERE conditions and needed joins.
     $where = implode(' AND ', $wherecond);
     if ($where !== '') {
         $where = 'WHERE ' . $where;
     }
     $joins = implode("\n", $joins);
 
-    // Ok, let's get the users!
+    // Finally! we have all the bits, run the query.
     $sql = "SELECT $fields
               FROM {user} u
             $joins
@@ -4063,7 +4105,7 @@ function get_user_capability_course($capability, $userid = null, $doanything = t
     $fieldlist = '';
     if ($fieldsexceptid) {
         $fields = array_map('trim', explode(',', $fieldsexceptid));
-        foreach($fields as $field) {
+        foreach ($fields as $field) {
             // Context fields have a different alias.
             if (strpos($field, 'ctx') === 0) {
                 switch($field) {
@@ -4086,7 +4128,7 @@ function get_user_capability_course($capability, $userid = null, $doanything = t
     if ($orderby) {
         $fields = explode(',', $orderby);
         $orderby = '';
-        foreach($fields as $field) {
+        foreach ($fields as $field) {
             if ($orderby) {
                 $orderby .= ',';
             }
@@ -4576,7 +4618,7 @@ function get_roles_with_cap_in_context($context, $capability) {
 
     $forbidden = array();
     $needed    = array();
-    foreach($capdefs as $def) {
+    foreach ($capdefs as $def) {
         if (isset($forbidden[$def->roleid])) {
             continue;
         }
@@ -4596,7 +4638,7 @@ function get_roles_with_cap_in_context($context, $capability) {
     unset($capdefs);
 
     // remove all those roles not allowing
-    foreach($needed as $key=>$value) {
+    foreach ($needed as $key=>$value) {
         if (!$value) {
             unset($needed[$key]);
         } else {
@@ -4618,7 +4660,7 @@ function get_roles_with_cap_in_context($context, $capability) {
 function get_roles_with_caps_in_context($context, $capabilities) {
     $neededarr = array();
     $forbiddenarr = array();
-    foreach($capabilities as $caprequired) {
+    foreach ($capabilities as $caprequired) {
         list($neededarr[], $forbiddenarr[]) = get_roles_with_cap_in_context($context, $caprequired);
     }
 
@@ -4732,7 +4774,7 @@ function role_change_permission($roleid, $context, $capname, $permission) {
           ORDER BY ctx.depth DESC";
 
     if ($existing = $DB->get_records_sql($sql, $params)) {
-        foreach($existing as $e) {
+        foreach ($existing as $e) {
             if ($e->permission == CAP_PROHIBIT) {
                 // prohibit can not be overridden, no point in changing anything
                 return;
@@ -4889,7 +4931,7 @@ abstract class context extends stdClass implements IteratorAggregate {
 
         if (self::$cache_count >= CONTEXT_CACHE_MAX_SIZE) {
             $i = 0;
-            foreach(self::$cache_contextsbyid as $ctx) {
+            foreach (self::$cache_contextsbyid as $ctx) {
                 $i++;
                 if ($i <= 100) {
                     // we want to keep the first contexts to be loaded on this page, hopefully they will be needed again later
@@ -7586,160 +7628,3 @@ function get_with_capability_sql(context $context, $capability) {
 
     return array($sql, $capjoin->params);
 }
-
-/**
- * Gets sql joins for finding users with capability in the given context
- *
- * @param context $context Context for the join
- * @param string|array $capability Capability name or array of names.
- *      If an array is provided then this is the equivalent of a logical 'OR',
- *      i.e. the user needs to have one of these capabilities.
- * @param string $useridcolumn e.g. 'u.id'
- * @return \core\dml\sql_join Contains joins, wheres, params
- */
-function get_with_capability_join(context $context, $capability, $useridcolumn) {
-    global $DB, $CFG;
-
-    // Use unique prefix just in case somebody makes some SQL magic with the result.
-    static $i = 0;
-    $i++;
-    $prefix = 'eu' . $i . '_';
-
-    // First find the course context.
-    $coursecontext = $context->get_course_context();
-
-    $isfrontpage = ($coursecontext->instanceid == SITEID);
-
-    $joins = array();
-    $wheres = array();
-    $params = array();
-
-    list($contextids, $contextpaths) = get_context_info_list($context);
-
-    list($incontexts, $cparams) = $DB->get_in_or_equal($contextids, SQL_PARAMS_NAMED, 'ctx');
-
-    list($incaps, $capsparams) = $DB->get_in_or_equal($capability, SQL_PARAMS_NAMED, 'cap');
-
-    // Check whether context locking is enabled.
-    // Filter out any write capability if this is the case.
-    $excludelockedcaps = '';
-    $excludelockedcapsparams = [];
-    if (!empty($CFG->contextlocking) && $context->locked) {
-        $excludelockedcaps = 'AND (cap.captype = :capread OR cap.name = :managelockscap)';
-        $excludelockedcapsparams['capread'] = 'read';
-        $excludelockedcapsparams['managelockscap'] = 'moodle/site:managecontextlocks';
-    }
-
-    $defs = array();
-    $sql = "SELECT rc.id, rc.roleid, rc.permission, ctx.path
-              FROM {role_capabilities} rc
-              JOIN {capabilities} cap ON rc.capability = cap.name
-              JOIN {context} ctx on rc.contextid = ctx.id
-             WHERE rc.contextid $incontexts AND rc.capability $incaps $excludelockedcaps";
-    $rcs = $DB->get_records_sql($sql, array_merge($cparams, $capsparams, $excludelockedcapsparams));
-    foreach ($rcs as $rc) {
-        $defs[$rc->path][$rc->roleid] = $rc->permission;
-    }
-
-    $access = array();
-    if (!empty($defs)) {
-        foreach ($contextpaths as $path) {
-            if (empty($defs[$path])) {
-                continue;
-            }
-            foreach ($defs[$path] as $roleid => $perm) {
-                if ($perm == CAP_PROHIBIT) {
-                    $access[$roleid] = CAP_PROHIBIT;
-                    continue;
-                }
-                if (!isset($access[$roleid])) {
-                    $access[$roleid] = (int) $perm;
-                }
-            }
-        }
-    }
-
-    unset($defs);
-
-    // Make lists of roles that are needed and prohibited.
-    $needed = array(); // One of these is enough.
-    $prohibited = array(); // Must not have any of these.
-    foreach ($access as $roleid => $perm) {
-        if ($perm == CAP_PROHIBIT) {
-            unset($needed[$roleid]);
-            $prohibited[$roleid] = true;
-        } else {
-            if ($perm == CAP_ALLOW and empty($prohibited[$roleid])) {
-                $needed[$roleid] = true;
-            }
-        }
-    }
-
-    $defaultuserroleid = isset($CFG->defaultuserroleid) ? $CFG->defaultuserroleid : 0;
-    $defaultfrontpageroleid = isset($CFG->defaultfrontpageroleid) ? $CFG->defaultfrontpageroleid : 0;
-
-    $nobody = false;
-
-    if ($isfrontpage) {
-        if (!empty($prohibited[$defaultuserroleid]) or !empty($prohibited[$defaultfrontpageroleid])) {
-            $nobody = true;
-        } else {
-            if (!empty($needed[$defaultuserroleid]) or !empty($needed[$defaultfrontpageroleid])) {
-                // Everybody not having prohibit has the capability.
-                $needed = array();
-            } else {
-                if (empty($needed)) {
-                    $nobody = true;
-                }
-            }
-        }
-    } else {
-        if (!empty($prohibited[$defaultuserroleid])) {
-            $nobody = true;
-        } else {
-            if (!empty($needed[$defaultuserroleid])) {
-                // Everybody not having prohibit has the capability.
-                $needed = array();
-            } else {
-                if (empty($needed)) {
-                    $nobody = true;
-                }
-            }
-        }
-    }
-
-    if ($nobody) {
-        // Nobody can match so return some SQL that does not return any results.
-        $wheres[] = "1 = 2";
-
-    } else {
-
-        if ($needed) {
-            $ctxids = implode(',', $contextids);
-            $roleids = implode(',', array_keys($needed));
-            $joins[] = "JOIN {role_assignments} {$prefix}ra3
-                    ON ({$prefix}ra3.userid = $useridcolumn
-                    AND {$prefix}ra3.roleid IN ($roleids)
-                    AND {$prefix}ra3.contextid IN ($ctxids))";
-        }
-
-        if ($prohibited) {
-            $ctxids = implode(',', $contextids);
-            $roleids = implode(',', array_keys($prohibited));
-            $joins[] = "LEFT JOIN {role_assignments} {$prefix}ra4
-                    ON ({$prefix}ra4.userid = $useridcolumn
-                    AND {$prefix}ra4.roleid IN ($roleids)
-                    AND {$prefix}ra4.contextid IN ($ctxids))";
-            $wheres[] = "{$prefix}ra4.id IS NULL";
-        }
-
-    }
-
-    $wheres[] = "$useridcolumn <> :{$prefix}guestid";
-    $params["{$prefix}guestid"] = $CFG->siteguest;
-
-    $joins = implode("\n", $joins);
-    $wheres = "(" . implode(" AND ", $wheres) . ")";
-
-    return new \core\dml\sql_join($joins, $wheres, $params);
-}
index 1920a3f..1b2d229 100644 (file)
@@ -31,6 +31,14 @@ defined('MOODLE_INTERNAL') || die();
 /**
  * An object that contains sql join fragments.
  *
+ * An example of how to use this class in a simple query, where you have got
+ * a join that is a join to the user table:
+ *
+ * $users = $DB->get_records_sql("SELECT u.*
+ *         FROM {user} u
+ *         {$sqljoin->joins}
+ *         WHERE {$sqljoin->wheres}", $sqljoin->params);
+ *
  * @since      Moodle 3.1
  * @package    core
  * @category   dml
@@ -54,16 +62,31 @@ class sql_join {
      */
     public $params;
 
+    /**
+     * @var bool if true this join is guaranteed to never match any rows.
+     *      In this case, the calling code may be able to completely
+     *      skip doing the database query.
+     * @since Moodle 3.9/3.8.3/3.7.6.
+     */
+    public $cannotmatchanyrows;
+
     /**
      * Create an object that contains sql join fragments.
      *
+     * Note, even if you set $cannotmatchanyrows to true, it is
+     * important to also set the other fields because the calling
+     * code is not required to check it. For example
+     * new \core\dml\sql_join('', '1 = 2', [], true);
+     *
      * @param string $joins The join sql fragment.
      * @param string $wheres The where sql fragment.
      * @param array $params Any parameter values.
+     * @param bool $cannotmatchanyrows If true, this join is guaranteed to match no rows. See comment on the field above.
      */
-    public function __construct($joins = '', $wheres = '', $params = array()) {
+    public function __construct($joins = '', $wheres = '', $params = array(), $cannotmatchanyrows = false) {
         $this->joins = $joins;
         $this->wheres = $wheres;
         $this->params = $params;
+        $this->cannotmatchanyrows = $cannotmatchanyrows;
     }
 }
diff --git a/lib/classes/event/contentbank_content_created.php b/lib/classes/event/contentbank_content_created.php
new file mode 100644 (file)
index 0000000..bf651c7
--- /dev/null
@@ -0,0 +1,137 @@
+<?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/>.
+
+/**
+ * Contentbank content created event.
+ *
+ * @package    core
+ * @copyright  2020 Amaia Anabitarte <amaia@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core\event;
+
+/**
+ * Content bank content created class.
+ *
+ * @property-read array $other {
+ *      Extra information about event.
+ *      - string contenttype: the contenttype of the content.
+ *      - string name: the name of the content.
+ * }
+ *
+ * @package    core
+ * @since      Moodle 3.9
+ * @copyright  2020 Amaia Anabitarte <amaia@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class contentbank_content_created extends base {
+
+    /**
+     * Initialise the event data.
+     */
+    protected function init() {
+        $this->data['objecttable'] = 'contentbank_content';
+        $this->data['crud'] = 'c';
+        $this->data['edulevel'] = self::LEVEL_OTHER;
+    }
+
+    /**
+     * Creates an event from content bank content object
+     *
+     * @since Moodle 3.9
+     * @param \stdClass $record Data to create the event
+     * @return contentbank_content_created
+     */
+    public static function create_from_record(\stdClass $record) {
+        $event = self::create([
+            'objectid' => $record->id,
+            'relateduserid' => $record->usercreated,
+            'context' => \context::instance_by_id($record->contextid),
+            'other' => [
+                'contenttype' => $record->contenttype,
+                'name' => $record->name
+            ]
+        ]);
+        return $event;
+    }
+
+    /**
+     * Returns localised general event name.
+     *
+     * @return string
+     */
+    public static function get_name() {
+        return get_string('eventcontentcreated', 'core_contentbank');
+    }
+
+    /**
+     * Returns non-localised description of what happened.
+     *
+     * @return string
+     */
+    public function get_description() {
+        return "The user with id '$this->userid' created the content with id '$this->objectid'.";
+    }
+
+    /**
+     * Custom validation.
+     *
+     * @throws \coding_exception
+     * @return void
+     */
+    protected function validate_data() {
+        parent::validate_data();
+
+        if (!isset($this->other['contenttype'])) {
+            throw new \coding_exception('The \'contenttype\' value must be set in other.');
+        }
+
+        if (!isset($this->other['name'])) {
+            throw new \coding_exception('The \'name\' value must be set in other.');
+        }
+    }
+
+    /**
+     * Returns relevant URL.
+     *
+     * @return \moodle_url
+     */
+    public function get_url() {
+        $url = new \moodle_url('/contentbank/view.php');
+        $url->param('id', $this->objectid);
+        return $url;
+    }
+
+    /**
+     * Used for mapping events on restore
+     *
+     * @return array
+     */
+    public static function get_objectid_mapping() {
+        return array('db' => 'contentbank_content', 'restore' => 'contentbank_content');
+    }
+
+    /**
+     * Used for mapping events on restore
+     *
+     * @return bool
+     */
+    public static function get_other_mapping() {
+        // No mapping required.
+        return false;
+    }
+}
diff --git a/lib/classes/event/contentbank_content_deleted.php b/lib/classes/event/contentbank_content_deleted.php
new file mode 100644 (file)
index 0000000..6021d5a
--- /dev/null
@@ -0,0 +1,126 @@
+<?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/>.
+
+/**
+ * Contentbank content deleted event.
+ *
+ * @package    core
+ * @copyright  2020 Amaia Anabitarte <amaia@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core\event;
+
+/**
+ * Content bank content deleted class.
+ *
+ * @property-read array $other {
+ *      Extra information about event.
+ *      - string contenttype: the contenttype of the content.
+ *      - string name: the name of the content.
+ * }
+ *
+ * @package    core
+ * @since      Moodle 3.9
+ * @copyright  2020 Amaia Anabitarte <amaia@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class contentbank_content_deleted extends base {
+
+    /**
+     * Initialise the event data.
+     */
+    protected function init() {
+        $this->data['objecttable'] = 'contentbank_content';
+        $this->data['crud'] = 'd';
+        $this->data['edulevel'] = self::LEVEL_OTHER;
+    }
+
+    /**
+     * Creates an event from content bank content object
+     *
+     * @since Moodle 3.9
+     * @param \stdClass $record Data to create the event
+     * @return contentbank_content_deleted
+     */
+    public static function create_from_record(\stdClass $record) {
+        $event = self::create([
+            'objectid' => $record->id,
+            'relateduserid' => $record->usercreated,
+            'context' => \context::instance_by_id($record->contextid),
+            'other' => [
+                'contenttype' => $record->contenttype,
+                'name' => $record->name
+            ]
+        ]);
+        return $event;
+    }
+
+    /**
+     * Returns localised general event name.
+     *
+     * @return string
+     */
+    public static function get_name() {
+        return get_string('eventcontentdeleted', 'core_contentbank');
+    }
+
+    /**
+     * Returns non-localised description of what happened.
+     *
+     * @return string
+     */
+    public function get_description() {
+        return "The user with id '$this->userid' deleted the content with id '$this->objectid'.";
+    }
+
+    /**
+     * Custom validation.
+     *
+     * @throws \coding_exception
+     * @return void
+     */
+    protected function validate_data() {
+        parent::validate_data();
+
+        if (!isset($this->other['contenttype'])) {
+            throw new \coding_exception('The \'contenttype\' value must be set in other.');
+        }
+
+        if (!isset($this->other['name'])) {
+            throw new \coding_exception('The \'name\' value must be set in other.');
+        }
+    }
+
+    /**
+     * Used for mapping events on restore
+     *
+     * @return array
+     */
+    public static function get_objectid_mapping() {
+        return array('db' => 'contentbank_content', 'restore' => 'contentbank_content');
+    }
+
+    /**
+     * Used for mapping events on restore
+     *
+     * @return bool
+     */
+    public static function get_other_mapping() {
+        // No mapping required.
+        return false;
+    }
+}
diff --git a/lib/classes/event/contentbank_content_updated.php b/lib/classes/event/contentbank_content_updated.php
new file mode 100644 (file)
index 0000000..74a3420
--- /dev/null
@@ -0,0 +1,137 @@
+<?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/>.
+
+/**
+ * Contentbank content uploaded event.
+ *
+ * @package    core
+ * @copyright  2020 Amaia Anabitarte <amaia@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core\event;
+
+/**
+ * Content bank content updated class.
+ *
+ * @property-read array $other {
+ *      Extra information about event.
+ *      - string contenttype: the contenttype of the content.
+ *      - string name: the name of the content.
+ * }
+ *
+ * @package    core
+ * @since      Moodle 3.9
+ * @copyright  2020 Amaia Anabitarte <amaia@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class contentbank_content_updated extends base {
+
+    /**
+     * Initialise the event data.
+     */
+    protected function init() {
+        $this->data['objecttable'] = 'contentbank_content';
+        $this->data['crud'] = 'u';
+        $this->data['edulevel'] = self::LEVEL_OTHER;
+    }
+
+    /**
+     * Creates an event from content bank content object
+     *
+     * @since Moodle 3.9
+     * @param \stdClass $record Data to create the event
+     * @return contentbank_content_updated
+     */
+    public static function create_from_record(\stdClass $record) {
+        $event = self::create([
+            'objectid' => $record->id,
+            'relateduserid' => $record->usercreated,
+            'context' => \context::instance_by_id($record->contextid),
+            'other' => [
+                'contenttype' => $record->contenttype,
+                'name' => $record->name
+            ]
+        ]);
+        return $event;
+    }
+
+    /**
+     * Returns localised general event name.
+     *
+     * @return string
+     */
+    public static function get_name() {
+        return get_string('eventcontentupdated', 'core_contentbank');
+    }
+
+    /**
+     * Returns non-localised description of what happened.
+     *
+     * @return string
+     */
+    public function get_description() {
+        return "The user with id '$this->userid' updated the content with id '$this->objectid'.";
+    }
+
+    /**
+     * Custom validation.
+     *
+     * @throws \coding_exception
+     * @return void
+     */
+    protected function validate_data() {
+        parent::validate_data();
+
+        if (!isset($this->other['contenttype'])) {
+            throw new \coding_exception('The \'contenttype\' value must be set in other.');
+        }
+
+        if (!isset($this->other['name'])) {
+            throw new \coding_exception('The \'name\' value must be set in other.');
+        }
+    }
+
+    /**
+     * Returns relevant URL.
+     *
+     * @return \moodle_url
+     */
+    public function get_url() {
+        $url = new \moodle_url('/contentbank/view.php');
+        $url->param('id', $this->objectid);
+        return $url;
+    }
+
+    /**
+     * Used for mapping events on restore
+     *
+     * @return array
+     */
+    public static function get_objectid_mapping() {
+        return array('db' => 'contentbank_content', 'restore' => 'contentbank_content');
+    }
+
+    /**
+     * Used for mapping events on restore
+     *
+     * @return bool
+     */
+    public static function get_other_mapping() {
+        // No mapping required.
+        return false;
+    }
+}
diff --git a/lib/classes/event/contentbank_content_uploaded.php b/lib/classes/event/contentbank_content_uploaded.php
new file mode 100644 (file)
index 0000000..1080407
--- /dev/null
@@ -0,0 +1,137 @@
+<?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/>.
+
+/**
+ * Contentbank content uploaded event.
+ *
+ * @package    core
+ * @copyright  2020 Amaia Anabitarte <amaia@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core\event;
+
+/**
+ * Content bank content uploaded class.
+ *
+ * @property-read array $other {
+ *      Extra information about event.
+ *      - string contenttype: the contenttype of the content.
+ *      - string name: the name of the content.
+ * }
+ *
+ * @package    core
+ * @since      Moodle 3.9
+ * @copyright  2020 Amaia Anabitarte <amaia@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class contentbank_content_uploaded extends base {
+
+    /**
+     * Initialise the event data.
+     */
+    protected function init() {
+        $this->data['objecttable'] = 'contentbank_content';
+        $this->data['crud'] = 'c';
+        $this->data['edulevel'] = self::LEVEL_OTHER;
+    }
+
+    /**
+     * Creates an event from content bank content object
+     *
+     * @since Moodle 3.9
+     * @param \stdClass $record Data to create the event
+     * @return contentbank_content_uploaded
+     */
+    public static function create_from_record(\stdClass $record) {
+        $event = self::create([
+            'objectid' => $record->id,
+            'relateduserid' => $record->usercreated,
+            'context' => \context::instance_by_id($record->contextid),
+            'other' => [
+                'contenttype' => $record->contenttype,
+                'name' => $record->name
+            ]
+        ]);
+        return $event;
+    }
+
+    /**
+     * Returns localised general event name.
+     *
+     * @return string
+     */
+    public static function get_name() {
+        return get_string('eventcontentuploaded', 'core_contentbank');
+    }
+
+    /**
+     * Returns non-localised description of what happened.
+     *
+     * @return string
+     */
+    public function get_description() {
+        return "The user with id '$this->userid' uploaded the content with id '$this->objectid'.";
+    }
+
+    /**
+     * Custom validation.
+     *
+     * @throws \coding_exception
+     * @return void
+     */
+    protected function validate_data() {
+        parent::validate_data();
+
+        if (!isset($this->other['contenttype'])) {
+            throw new \coding_exception('The \'contenttype\' value must be set in other.');
+        }
+
+        if (!isset($this->other['name'])) {
+            throw new \coding_exception('The \'name\' value must be set in other.');
+        }
+    }
+
+    /**
+     * Returns relevant URL.
+     *
+     * @return \moodle_url
+     */
+    public function get_url() {
+        $url = new \moodle_url('/contentbank/view.php');
+        $url->param('id', $this->objectid);
+        return $url;
+    }
+
+    /**
+     * Used for mapping events on restore
+     *
+     * @return array
+     */
+    public static function get_objectid_mapping() {
+        return array('db' => 'contentbank_content', 'restore' => 'contentbank_content');
+    }
+
+    /**
+     * Used for mapping events on restore
+     *
+     * @return bool
+     */
+    public static function get_other_mapping() {
+        // No mapping required.
+        return false;
+    }
+}
diff --git a/lib/classes/event/contentbank_content_viewed.php b/lib/classes/event/contentbank_content_viewed.php
new file mode 100644 (file)
index 0000000..cd8b4d2
--- /dev/null
@@ -0,0 +1,137 @@
+<?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/>.
+
+/**
+ * Contentbank content viewed event.
+ *
+ * @package    core
+ * @copyright  2020 Amaia Anabitarte <amaia@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core\event;
+
+/**
+ * Content bank content updated class.
+ *
+ * @property-read array $other {
+ *      Extra information about event.
+ *      - string contenttype: the contenttype of the content.
+ *      - string name: the name of the content.
+ * }
+ *
+ * @package    core
+ * @since      Moodle 3.9
+ * @copyright  2020 Amaia Anabitarte <amaia@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class contentbank_content_viewed extends base {
+
+    /**
+     * Initialise the event data.
+     */
+    protected function init() {
+        $this->data['objecttable'] = 'contentbank_content';
+        $this->data['crud'] = 'r';
+        $this->data['edulevel'] = self::LEVEL_OTHER;
+    }
+
+    /**
+     * Creates an event from content bank content object
+     *
+     * @since Moodle 3.9
+     * @param \stdClass $record Data to create the event
+     * @return contentbank_content_viewed
+     */
+    public static function create_from_record(\stdClass $record) {
+        $event = self::create([
+            'objectid' => $record->id,
+            'relateduserid' => $record->usercreated,
+            'context' => \context::instance_by_id($record->contextid),
+            'other' => [
+                'contenttype' => $record->contenttype,
+                'name' => $record->name
+            ]
+        ]);
+        return $event;
+    }
+
+    /**
+     * Returns localised general event name.
+     *
+     * @return string
+     */
+    public static function get_name() {
+        return get_string('eventcontentviewed', 'core_contentbank');
+    }
+
+    /**
+     * Returns non-localised description of what happened.
+     *
+     * @return string
+     */
+    public function get_description() {
+        return "The user with id '$this->userid' viewed the content with id '$this->objectid'.";
+    }
+
+    /**
+     * Custom validation.
+     *
+     * @throws \coding_exception
+     * @return void
+     */
+    protected function validate_data() {
+        parent::validate_data();
+
+        if (!isset($this->other['contenttype'])) {
+            throw new \coding_exception('The \'contenttype\' value must be set in other.');
+        }
+
+        if (!isset($this->other['name'])) {
+            throw new \coding_exception('The \'name\' value must be set in other.');
+        }
+    }
+
+    /**
+     * Returns relevant URL.
+     *
+     * @return \moodle_url
+     */
+    public function get_url() {
+        $url = new \moodle_url('/contentbank/view.php');
+        $url->param('id', $this->objectid);
+        return $url;
+    }
+
+    /**
+     * Used for mapping events on restore
+     *
+     * @return array
+     */
+    public static function get_objectid_mapping() {
+        return array('db' => 'contentbank_content', 'restore' => 'contentbank_content');
+    }
+
+    /**
+     * Used for mapping events on restore
+     *
+     * @return bool
+     */
+    public static function get_other_mapping() {
+        // No mapping required.
+        return false;
+    }
+}
index e2b5c78..be495fe 100644 (file)
@@ -22,7 +22,7 @@ Feature: Atto accessibility checker
     And I select the text in the "Description" Atto editor
     And I click on "Insert or edit image" "button"
     And I set the field "Describe this image for someone who cannot see it" to ""
-    And I set the field "Description not necessary" to "1"
+    And I set the field "This image is decorative only" to "1"
     And I press "Save image"
     And I press "Accessibility checker"
     And I should see "Congratulations, no accessibility problems found!"
index e9bceb9..624247e 100644 (file)
@@ -36,7 +36,7 @@ $string['enteralt'] = 'Describe this image for someone who cannot see it';
 $string['enterurl'] = 'Enter URL';
 $string['height'] = 'Height';
 $string['imageproperties'] = 'Image properties';
-$string['presentation'] = 'Description not necessary';
+$string['presentation'] = 'This image is decorative only';
 $string['pluginname'] = 'Insert or edit image';
 $string['presentationoraltrequired'] = 'Images must have a description, except if the description is marked as not necessary.';
 $string['preview'] = 'Preview';
index 941acdf..c43debe 100644 (file)
@@ -39,8 +39,9 @@ Feature: Add images to Atto
     And I take focus off "Height" "field"
     And the field "Width" matches value "123"
     And the field "Height" matches value "456"
-    And I click on "Save image" "button"
-    And I click on "Update profile" "button"
+    And I change window size to "large"
+    And I press "Save image"
+    And I press "Update profile"
     And I click on "Edit profile" "link" in the "region-main" "region"
     And I select the text in the "Description" Atto editor
     And I click on "Insert or edit image" "button"
index bed9c39..75f44a5 100644 (file)
Binary files a/lib/editor/atto/plugins/image/yui/build/moodle-atto_image-button/moodle-atto_image-button-debug.js and b/lib/editor/atto/plugins/image/yui/build/moodle-atto_image-button/moodle-atto_image-button-debug.js differ
index fea59fe..d6b125e 100644 (file)
Binary files a/lib/editor/atto/plugins/image/yui/build/moodle-atto_image-button/moodle-atto_image-button-min.js and b/lib/editor/atto/plugins/image/yui/build/moodle-atto_image-button/moodle-atto_image-button-min.js differ
index 5c65d96..4bb3907 100644 (file)
Binary files a/lib/editor/atto/plugins/image/yui/build/moodle-atto_image-button/moodle-atto_image-button.js and b/lib/editor/atto/plugins/image/yui/build/moodle-atto_image-button/moodle-atto_image-button.js differ
index 7aa557a..774d8b1 100644 (file)
@@ -122,8 +122,14 @@ var CSS = {
                 '</div>' +
                 '<div class="mb-1">' +
                 '<label for="{{elementid}}_{{CSS.INPUTALT}}">{{get_string "enteralt" component}}</label>' +
-                '<input class="form-control fullwidth {{CSS.INPUTALT}}" type="text" value="" ' +
-                'id="{{elementid}}_{{CSS.INPUTALT}}" size="32"/>' +
+                '<textarea class="form-control fullwidth {{CSS.INPUTALT}}" ' +
+                'id="{{elementid}}_{{CSS.INPUTALT}}" maxlength="125"></textarea>' +
+
+                // Add the character count.
+                '<div id="the-count" class="d-flex justify-content-end small">' +
+                '<span id="currentcount">0</span>' +
+                '<span id="maximumcount"> / 125</span>' +
+                '</div>' +
 
                 // Add the presentation select box.
                 '<div class="form-check">' +
@@ -603,6 +609,9 @@ Y.namespace('M.atto_image').Button = Y.Base.create('button', Y.M.editor_atto.Edi
             }, this);
         }
 
+        // Character count.
+        this._form.one('.' + CSS.INPUTALT).on('keyup', this._handleKeyup, this);
+
         return content;
     },
 
@@ -1043,5 +1052,16 @@ Y.namespace('M.atto_image').Button = Y.Base.create('button', Y.M.editor_atto.Edi
         }
         this.getDialogue().centerDialogue();
         return state;
+    },
+
+    /**
+     * Handle the keyup to update the character count.
+     */
+    _handleKeyup: function() {
+        var form = this._form,
+            alt = form.one('.' + CSS.INPUTALT).get('value'),
+            characterCount = alt.length,
+            current = form.one('#currentcount');
+        current.setHTML(characterCount);
     }
 });
index 6449ef5..b62b377 100644 (file)
@@ -15,7 +15,7 @@ Feature: Atto italic button
     And I set the field "Text editor" to "Plain text area"
     And I press "Save changes"
     And I click on "Edit profile" "link" in the "region-main" "region"
-    Then I should see "<i>Tower of Pisa</i>"
+    Then I should see "<em>Tower of Pisa</em>"
 
   @javascript
   Scenario: Toggle italics in some text
@@ -31,5 +31,5 @@ Feature: Atto italic button
     And I set the field "Text editor" to "Plain text area"
     And I press "Save changes"
     And I click on "Edit profile" "link" in the "region-main" "region"
-    Then I should not see "<i>GHD - for hair</i>"
+    Then I should not see "<em>GHD - for hair</em>"
     And I should see "GHD - for hair"
index dcba9e9..d316663 100644 (file)
Binary files a/lib/editor/atto/plugins/italic/yui/build/moodle-atto_italic-button/moodle-atto_italic-button-debug.js and b/lib/editor/atto/plugins/italic/yui/build/moodle-atto_italic-button/moodle-atto_italic-button-debug.js differ
index 6a64c70..52208d1 100644 (file)
Binary files a/lib/editor/atto/plugins/italic/yui/build/moodle-atto_italic-button/moodle-atto_italic-button-min.js and b/lib/editor/atto/plugins/italic/yui/build/moodle-atto_italic-button/moodle-atto_italic-button-min.js differ
index dcba9e9..d316663 100644 (file)
Binary files a/lib/editor/atto/plugins/italic/yui/build/moodle-atto_italic-button/moodle-atto_italic-button.js and b/lib/editor/atto/plugins/italic/yui/build/moodle-atto_italic-button/moodle-atto_italic-button.js differ
index 8823c30..fadc5e5 100644 (file)
 
 Y.namespace('M.atto_italic').Button = Y.Base.create('button', Y.M.editor_atto.EditorPlugin, [], {
     initializer: function() {
-        this.addBasicButton({
-            exec: 'italic',
+        var italic;
+
+        this.addButton({
+            callback: this._toggleItalic,
+            icon: 'e/italic',
+            buttonName: italic,
+            inlineFormat: true,
 
             // Key code for the keyboard shortcut which triggers this button:
             keys: '73',
 
             // Watch the following tags and add/remove highlighting as appropriate:
-            tags: 'i'
+            tags: 'em, i'
         });
+    },
+    /**
+     * Toggle the italic setting.
+     *
+     * @method _toggleItalic
+     * @param {EventFacade} e
+     */
+    _toggleItalic: function() {
+        var host = this.get('host');
+
+        // Use the "italic" command for simplicity. This will toggle <em> tags off as well.
+        document.execCommand('italic', false, null);
+
+        // Then change all <i> tags to <em> tags. This will change any existing <i> tags as well.
+        host.changeToCSS('i', 'bf-editor-italic-emphasis');
+        host.changeToTags('bf-editor-italic-emphasis', 'em');
     }
 });
index 02f5665..ec282c2 100644 (file)
@@ -21,6 +21,6 @@ Feature: Atto editor with customised toolbar
     Then ".atto_link_button" "css_element" should exist in the ".normaldiv" "css_element"
     And ".atto_link_button" "css_element" should not exist in the ".specialdiv" "css_element"
     And ".atto_bold_button" "css_element" should exist in the ".normaldiv" "css_element"
-    And ".atto_italic_button_italic" "css_element" should exist in the ".normaldiv" "css_element"
+    And ".atto_italic_button" "css_element" should exist in the ".normaldiv" "css_element"
     And ".atto_bold_button" "css_element" should exist in the ".specialdiv" "css_element"
-    And ".atto_italic_button_italic" "css_element" should exist in the ".specialdiv" "css_element"
+    And ".atto_italic_button" "css_element" should exist in the ".specialdiv" "css_element"
index a3a6590..4e1d16a 100644 (file)
@@ -22,7 +22,7 @@ Feature: Atto with enable/disable function.
     Then the "disabled" attribute of "button.atto_collapse_button" "css_element" should contain "disabled"
     And the "disabled" attribute of "button.atto_title_button" "css_element" should contain "disabled"
     And the "disabled" attribute of "button.atto_bold_button" "css_element" should contain "disabled"
-    And the "disabled" attribute of "button.atto_italic_button_italic" "css_element" should contain "disabled"
+    And the "disabled" attribute of "button.atto_italic_button" "css_element" should contain "disabled"
     And the "disabled" attribute of "button.atto_unorderedlist_button_insertUnorderedList" "css_element" should contain "disabled"
     And the "disabled" attribute of "button.atto_orderedlist_button_insertOrderedList" "css_element" should contain "disabled"
     And the "disabled" attribute of "button.atto_link_button" "css_element" should contain "disabled"
@@ -36,7 +36,7 @@ Feature: Atto with enable/disable function.
     Then "button.atto_collapse_button[disabled]" "css_element" should not exist
     And "button.atto_title_button[disabled]" "css_element" should not exist
     And "button.atto_bold_button[disabled]" "css_element" should not exist
-    And "button.atto_italic_button_italic[disabled]" "css_element" should not exist
+    And "button.atto_italic_button[disabled]" "css_element" should not exist
     And "button.atto_unorderedlist_button_insertUnorderedList[disabled]" "css_element" should not exist
     And "button.atto_orderedlist_button_insertOrderedList[disabled]" "css_element" should not exist
     And "button.atto_link_button[disabled]" "css_element" should not exist
index 8b95027..8f2c834 100644 (file)
@@ -2407,13 +2407,16 @@ function core_question_find_next_unused_idnumber(?string $oldidnumber, int $cate
             [$categoryid], '', 'idnumber, 1');
 
     // Find the next unused idnumber.
-    $newidnumber = $oldidnumber;
+    $numberbit = 'X' . $matches[0]; // Need a string here so PHP does not do '0001' + 1 = 2.
+    $stem = substr($oldidnumber, 0, -strlen($matches[0]));
     do {
+
         // If we have got to something9999, insert an extra digit before incrementing.
-        if (preg_match('~^(.*[^0-9])(9+)$~', $newidnumber, $matches)) {
-            $newidnumber = $matches[1] . '0' . $matches[2];
+        if (preg_match('~^(.*[^0-9])(9+)$~', $numberbit, $matches)) {
+            $numberbit = $matches[1] . '0' . $matches[2];
         }
-        $newidnumber++;
+        $numberbit++;
+        $newidnumber = $stem . substr($numberbit, 1);
     } while (isset($usedidnumbers[$newidnumber]));
 
     return (string) $newidnumber;
index 69210d8..127cd83 100644 (file)
Binary files a/lib/table/amd/build/dynamic.min.js and b/lib/table/amd/build/dynamic.min.js differ
index 9f3aae9..fd3cbd2 100644 (file)
Binary files a/lib/table/amd/build/dynamic.min.js.map and b/lib/table/amd/build/dynamic.min.js.map differ
index a377b54..ec438f1 100644 (file)
Binary files a/lib/table/amd/build/local/dynamic/repository.min.js and b/lib/table/amd/build/local/dynamic/repository.min.js differ
index 1f58475..af07af3 100644 (file)
Binary files a/lib/table/amd/build/local/dynamic/repository.min.js.map and b/lib/table/amd/build/local/dynamic/repository.min.js.map differ
index aa1d030..18e35fb 100644 (file)
@@ -61,9 +61,10 @@ const getFiltersetFromTable = tableRoot => {
  * Update the specified table based on its current values.
  *
  * @param {HTMLElement} tableRoot
+ * @param {Bool} resetContent
  * @returns {Promise}
  */
-export const refreshTableContent = tableRoot => {
+export const refreshTableContent = (tableRoot, resetContent = false) => {
     const filterset = getFiltersetFromTable(tableRoot);
 
     return fetchTableData(
@@ -80,7 +81,8 @@ export const refreshTableContent = tableRoot => {
             pageNumber: tableRoot.dataset.tablePageNumber,
             pageSize: tableRoot.dataset.tablePageSize,
             hiddenColumns: JSON.parse(tableRoot.dataset.tableHiddenColumns),
-        }
+        },
+        resetContent,
     )
     .then(data => {
         const placeholder = document.createElement('div');
@@ -246,6 +248,14 @@ export const showColumn = (tableRoot, columnToShow, refreshContent = true) => {
     updateTable(tableRoot, {hiddenColumns}, refreshContent);
 };
 
+/**
+ * Reset table preferences.
+ *
+ * @param {HTMLElement} tableRoot
+ * @returns {Promise}
+ */
+const resetTablePreferences = tableRoot => refreshTableContent(tableRoot, true);
+
 /**
  * Set up listeners to handle table updates.
  */
@@ -305,6 +315,12 @@ export const init = () => {
             showColumn(tableRoot, show.dataset.column);
         }
 
+        const resetTablePreferencesLink = e.target.closest('.resettable a');
+        if (resetTablePreferencesLink) {
+            e.preventDefault();
+
+            resetTablePreferences(tableRoot);
+        }
     });
 };
 
index 344de98..a8e6141 100644 (file)
@@ -37,6 +37,7 @@ import {call as fetchMany} from 'core/ajax';
  * @param {String} pageNumber The page number
  * @param {Number} pageSize The page size
  * @param {Number} params parameters to request table
+ * @param {Bool} resetPreferences
  * @return {Promise} Resolved with requested table view
  */
 export const fetch = (component, handler, uniqueid, {
@@ -49,8 +50,7 @@ export const fetch = (component, handler, uniqueid, {
         pageNumber = null,
         pageSize = null,
         hiddenColumns = {}
-    } = {}
-) => {
+    } = {}, resetPreferences = false) => {
     return fetchMany([{
         methodname: `core_table_dynamic_fetch`,
         args: {
@@ -66,6 +66,7 @@ export const fetch = (component, handler, uniqueid, {
             pagenumber: pageNumber,
             pagesize: pageSize,
             hiddencolumns: hiddenColumns,
+            resetpreferences: resetPreferences
         },
     }])[0];
 };
index 890efb5..15b9583 100644 (file)
@@ -123,6 +123,12 @@ class fetch extends external_api {
                     null
                 )
             ),
+            'resetpreferences' => new external_value(
+                PARAM_BOOL,
+                'Whether the table preferences should be reset',
+                VALUE_REQUIRED,
+                null
+            ),
         ]);
     }
 
@@ -140,6 +146,8 @@ class fetch extends external_api {
      * @param string $lastinitial The last name initial to filter on
      * @param int $pagenumber The page number.
      * @param int $pagesize The number of records.
+     * @param string $jointype The join type.
+     * @param bool $resetpreferences Whether it is resetting table preferences or not.
      *
      * @return array
      */
@@ -155,7 +163,8 @@ class fetch extends external_api {
         ?string $lastinitial = null,
         ?int $pagenumber = null,
         ?int $pagesize = null,
-        ?array $hiddencolumns = null
+        ?array $hiddencolumns = null,
+        ?bool $resetpreferences = null
     ) {
 
         global $PAGE;
@@ -173,6 +182,7 @@ class fetch extends external_api {
             'pagenumber' => $pagenumber,
             'pagesize' => $pagesize,
             'hiddencolumns' => $hiddencolumns,
+            'resetpreferences' => $resetpreferences,
         ] = self::validate_parameters(self::execute_parameters(), [
             'component' => $component,
             'handler' => $handler,
@@ -186,6 +196,7 @@ class fetch extends external_api {
             'pagenumber' => $pagenumber,
             'pagesize' => $pagesize,
             'hiddencolumns' => $hiddencolumns,
+            'resetpreferences' => $resetpreferences,
         ]);
 
         $tableclass = "\\{$component}\\table\\{$handler}";
@@ -238,6 +249,12 @@ class fetch extends external_api {
             $instance->set_hidden_columns($hiddencolumns);
         }
 
+        if ($resetpreferences === true) {
+            $instance->mark_table_to_reset();
+        }
+
+        $PAGE->set_url($instance->baseurl);
+
         ob_start();
         $instance->out($pagesize, true);
         $tablehtml = ob_get_contents();
index 55a83c2..6cb2a6b 100644 (file)
@@ -158,6 +158,9 @@ class flexible_table {
     /** @var array $hiddencolumns List of hidden columns. */
     protected $hiddencolumns;
 
+    /** @var $resetting bool Whether the table preferences is resetting. */
+    protected $resetting;
+
     /**
      * @var filterset The currently applied filerset
      * This is required for dynamic tables, but can be used by other tables too if desired.
@@ -485,55 +488,38 @@ class flexible_table {
         $this->helpforheaders = $helpicons;
     }
 
+    /**
+     * Mark the table preferences to be reset.
+     */
+    public function mark_table_to_reset(): void {
+        $this->resetting = true;
+    }
+
+    /**
+     * Is the table marked for reset preferences?
+     *
+     * @return bool True if the table is marked to reset, false otherwise.
+     */
+    protected function is_resetting_preferences(): bool {
+        if ($this->resetting === null) {
+            $this->resetting = optional_param($this->request[TABLE_VAR_RESET], false, PARAM_BOOL);
+        }
+
+        return $this->resetting;
+}
+
     /**
      * Must be called after table is defined. Use methods above first. Cannot
      * use functions below till after calling this method.
      * @return type?
      */
     function setup() {
-        global $SESSION;
 
         if (empty($this->columns) || empty($this->uniqueid)) {
             return false;
         }
 
-        // Load any existing user preferences.
-        if ($this->persistent) {
-            $this->prefs = json_decode(get_user_preferences('flextable_' . $this->uniqueid), true);
-            $oldprefs = $this->prefs;
-        } else if (isset($SESSION->flextable[$this->uniqueid])) {
-            $this->prefs = $SESSION->flextable[$this->uniqueid];
-            $oldprefs = $this->prefs;
-        }
-
-        // Set up default preferences if needed.
-        if (!$this->prefs or optional_param($this->request[TABLE_VAR_RESET], false, PARAM_BOOL)) {
-            $this->prefs = array(
-                'collapse' => array(),
-                'sortby'   => array(),
-                'i_first'  => '',
-                'i_last'   => '',
-                'textsort' => $this->column_textsort,
-            );
-        }
-
-        if (!isset($oldprefs)) {
-            $oldprefs = $this->prefs;
-        }
-
-        $this->set_hide_show_preferences();
-        $this->set_sorting_preferences();
-        $this->set_initials_preferences();
-
-        // Save user preferences if they have changed.
-        if ($this->prefs != $oldprefs) {
-            if ($this->persistent) {
-                set_user_preference('flextable_' . $this->uniqueid, json_encode($this->prefs));
-            } else {
-                $SESSION->flextable[$this->uniqueid] = $this->prefs;
-            }
-        }
-        unset($oldprefs);
+        $this->initialise_table_preferences();
 
         if (empty($this->baseurl)) {
             debugging('You should set baseurl when using flexible_table.');
@@ -1413,6 +1399,103 @@ class flexible_table {
         $this->hiddencolumns = $columns;
     }
 
+    /**
+     * Initialise table preferences.
+     */
+    protected function initialise_table_preferences(): void {
+        global $SESSION;
+
+        // Load any existing user preferences.
+        if ($this->persistent) {
+            $this->prefs = json_decode(get_user_preferences('flextable_' . $this->uniqueid), true);
+            $oldprefs = $this->prefs;
+        } else if (isset($SESSION->flextable[$this->uniqueid])) {
+            $this->prefs = $SESSION->flextable[$this->uniqueid];
+            $oldprefs = $this->prefs;
+        }
+
+        // Set up default preferences if needed.
+        if (!$this->prefs || $this->is_resetting_preferences()) {
+            $this->prefs = [
+                'collapse' => [],
+                'sortby'   => [],
+                'i_first'  => '',
+                'i_last'   => '',
+                'textsort' => $this->column_textsort,
+            ];
+        }
+
+        if (!isset($oldprefs)) {
+            $oldprefs = $this->prefs;
+        }
+
+        // Save user preferences if they have changed.
+        if ($this->is_resetting_preferences()) {
+            $this->sortorder = null;
+            $this->sortby = null;
+            $this->ifirst = null;
+            $this->ilast = null;
+        }
+
+        if (($showcol = optional_param($this->request[TABLE_VAR_SHOW], '', PARAM_ALPHANUMEXT)) &&
+            isset($this->columns[$showcol])) {
+            $this->prefs['collapse'][$showcol] = false;
+        } else if (($hidecol = optional_param($this->request[TABLE_VAR_HIDE], '', PARAM_ALPHANUMEXT)) &&
+            isset($this->columns[$hidecol])) {
+            $this->prefs['collapse'][$hidecol] = true;
+            if (array_key_exists($hidecol, $this->prefs['sortby'])) {
+                unset($this->prefs['sortby'][$hidecol]);
+            }
+        }
+
+        // Now, update the column attributes for collapsed columns
+        foreach (array_keys($this->columns) as $column) {
+            if (!empty($this->prefs['collapse'][$column])) {
+                $this->column_style[$column]['width'] = '10px';
+            }
+        }
+
+        // Now, update the column attributes for collapsed columns
+        foreach (array_keys($this->columns) as $column) {
+            if (!empty($this->prefs['collapse'][$column])) {
+                $this->column_style[$column]['width'] = '10px';
+            }
+        }
+
+        $this->set_sorting_preferences();
+        $this->set_initials_preferences();
+
+        if (empty($this->baseurl)) {
+            debugging('You should set baseurl when using flexible_table.');
+            global $PAGE;
+            $this->baseurl = $PAGE->url;
+        }
+
+        if ($this->currpage == null) {
+            $this->currpage = optional_param($this->request[TABLE_VAR_PAGE], 0, PARAM_INT);
+        }
+
+        $this->save_preferences($oldprefs);
+    }
+
+    /**
+     * Save preferences.
+     *
+     * @param array $oldprefs Old preferences to compare against.
+     */
+    protected function save_preferences($oldprefs): void {
+        global $SESSION;
+
+        if ($this->prefs != $oldprefs) {
+            if ($this->persistent) {
+                set_user_preference('flextable_' . $this->uniqueid, json_encode($this->prefs));
+            } else {
+                $SESSION->flextable[$this->uniqueid] = $this->prefs;
+            }
+        }
+        unset($oldprefs);
+    }
+
     /**
      * Set the preferred table sorting attributes.
      *
index 8e6ce40..fcaf107 100644 (file)
@@ -3850,10 +3850,6 @@ class core_accesslib_testcase extends advanced_testcase {
         $this->assertFalse(array_key_exists($guest->id, $users));
     }
 
-    /**
-     * Test updating of role capabilities during upgrade
-     * @return void
-     */
     public function test_get_with_capability_sql() {
         global $DB;
 
@@ -3905,6 +3901,73 @@ class core_accesslib_testcase extends advanced_testcase {
         $this->assertFalse(array_key_exists($guest->id, $users));
     }
 
+
+    /**
+     * Get the test cases for {@link test_get_with_capability_join_when_overrides_present()}.
+     *
+     * The particular capabilties used here do not really matter. What is important is
+     * that they are capabilities which the Student roles has by default, but the
+     * authenticated suser role does not.
+     *
+     * @return array
+     */
+    public function get_get_with_capability_join_override_cases() {
+        return [
+                'no overrides' => [true, []],
+                'one override' => [true, ['moodle/course:viewscales']],
+                'both overrides' => [false, ['moodle/course:viewscales', 'moodle/question:flag']],
+        ];
+    }
+
+    /**
+     * Test get_with_capability_join.
+     *
+     * @dataProvider get_get_with_capability_join_override_cases
+     *
+     * @param bool $studentshouldbereturned whether, with this combination of capabilities, the student should be in the results.
+     * @param array $capabilitiestoprevent capabilities to override to prevent in the course context.
+     */
+    public function test_get_with_capability_join_when_overrides_present(
+            bool $studentshouldbereturned, array $capabilitiestoprevent) {
+        global $DB;
+        $this->resetAfterTest();
+        $generator = $this->getDataGenerator();
+
+        // Create a course.
+        $category = $generator->create_category();
+        $course = $generator->create_course(['category' => $category->id]);
+
+        // Create a user.
+        $student = $generator->create_user();
+        $studentrole = $DB->get_record('role', ['shortname' => 'student'], '*', MUST_EXIST);
+        $generator->enrol_user($student->id, $course->id, $studentrole->id);
+
+        // This test assumes that by default the student roles has the two
+        // capabilities. Check this now in case the role definitions are every changed.
+        $coursecontext = context_course::instance($course->id);
+        $this->assertTrue(has_capability('moodle/course:viewscales', $coursecontext, $student));
+        $this->assertTrue(has_capability('moodle/question:flag', $coursecontext, $student));
+
+        // We test cases where there are a varying number of prevent overrides.
+        foreach ($capabilitiestoprevent as $capability) {
+            role_change_permission($studentrole->id, $coursecontext, $capability, CAP_PREVENT);
+        }
+
+        // So now, assemble our query using the method under test, and verify that it returns the student.
+        $sqljoin = get_with_capability_join($coursecontext,
+                ['moodle/course:viewscales', 'moodle/question:flag'], 'u.id');
+
+        $users = $DB->get_records_sql("SELECT u.*
+                  FROM {user} u
+                       {$sqljoin->joins}
+                 WHERE {$sqljoin->wheres}", $sqljoin->params);
+        if ($studentshouldbereturned) {
+            $this->assertEquals([$student->id], array_keys($users));
+        } else {
+            $this->assertEmpty($users);
+        }
+    }
+
     /**
      * Test the get_profile_roles() function.
      */
diff --git a/lib/tests/event/contentbank_content_created_test.php b/lib/tests/event/contentbank_content_created_test.php
new file mode 100644 (file)
index 0000000..0e381b9
--- /dev/null
@@ -0,0 +1,77 @@
+<?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/>.
+
+/**
+ * Content bank created event tests.
+ *
+ * @package core
+ * @category test
+ * @copyright 2020 Amaia Anabitarte <amaia@moodle.com>
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core\event;
+
+/**
+ * Test for content bank created event.
+ *
+ * @package    core
+ * @category   test
+ * @copyright  2020 Amaia Anabitarte <amaia@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ * @coversDefaultClass \core\event\contentbank_content_created
+ */
+class contentbank_content_created_testcase extends \advanced_testcase {
+
+    /**
+     * Setup to ensure that fixtures are loaded.
+     */
+    public static function setUpBeforeClass() {
+        global $CFG;
+
+        require_once($CFG->dirroot . '/contentbank/tests/fixtures/testable_contenttype.php');
+        require_once($CFG->dirroot . '/contentbank/tests/fixtures/testable_content.php');
+    }
+
+    /**
+     * Test the content created event.
+     *
+     * @covers ::create_from_record
+     */
+    public function test_content_created() {
+
+        $this->resetAfterTest();
+        $this->setAdminUser();
+
+        // Save the system context.
+        $systemcontext = \context_system::instance();
+
+        // Trigger and capture the event when creating a content.
+        $sink = $this->redirectEvents();
+
+        // Create a content bank content.
+        $generator = $this->getDataGenerator()->get_plugin_generator('core_contentbank');
+        $contents = $generator->generate_contentbank_data('contenttype_testable', 1);
+        $content = array_shift($contents);
+
+        $events = $sink->get_events();
+        $event = reset($events);
+
+        // Check that the event data is valid.
+        $this->assertInstanceOf('\core\event\contentbank_content_created', $event);
+        $this->assertEquals($systemcontext, $event->get_context());
+    }
+}
diff --git a/lib/tests/event/contentbank_content_deleted_test.php b/lib/tests/event/contentbank_content_deleted_test.php
new file mode 100644 (file)
index 0000000..20c7517
--- /dev/null
@@ -0,0 +1,83 @@
+<?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/>.
+
+/**
+ * Content bank deleted event tests.
+ *
+ * @package core
+ * @category test
+ * @copyright 2020 Amaia Anabitarte <amaia@moodle.com>
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core\event;
+
+/**
+ * Test for content bank deleted event.
+ *
+ * @package    core
+ * @category   test
+ * @copyright  2020 Amaia Anabitarte <amaia@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ * @coversDefaultClass \core\event\contentbank_content_deleted
+ */
+class contentbank_content_deleted_testcase extends \advanced_testcase {
+
+    /**
+     * Setup to ensure that fixtures are loaded.
+     */
+    public static function setUpBeforeClass() {
+        global $CFG;
+
+        require_once($CFG->dirroot . '/contentbank/tests/fixtures/testable_contenttype.php');
+        require_once($CFG->dirroot . '/contentbank/tests/fixtures/testable_content.php');
+    }
+
+    /**
+     * Test the content deleted event
+     *
+     * @covers ::create_from_record
+     */
+    public function test_content_deleted() {
+        global $DB;
+
+        $this->resetAfterTest();
+        $this->setAdminUser();
+
+        // Save the system context.
+        $systemcontext = \context_system::instance();
+
+        // Create a content bank content.
+        $generator = $this->getDataGenerator()->get_plugin_generator('core_contentbank');
+        $contents = $generator->generate_contentbank_data('contenttype_testable', 3);
+        $content = array_shift($contents);
+        $this->assertEquals(3, $DB->count_records('contentbank_content'));
+
+        $classname = '\\contenttype_testable\\contenttype';
+        $contentype = new $classname($systemcontext);
+
+        // Trigger and capture the event for deleting a content.
+        $sink = $this->redirectEvents();
+        $contentype->delete_content($content);
+        $events = $sink->get_events();
+        $event = reset($events);
+
+        // Check that the content was deleted and the event data is valid.
+        $this->assertEquals(2, $DB->count_records('contentbank_content'));
+        $this->assertInstanceOf('\core\event\contentbank_content_deleted', $event);
+        $this->assertEquals(\context_system::instance(), $event->get_context());
+    }
+}
diff --git a/lib/tests/event/contentbank_content_updated_test.php b/lib/tests/event/contentbank_content_updated_test.php
new file mode 100644 (file)
index 0000000..b6aeae7
--- /dev/null
@@ -0,0 +1,82 @@
+<?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/>.
+
+/**
+ * Content bank updated event tests.
+ *
+ * @package core
+ * @category test
+ * @copyright 2020 Amaia Anabitarte <amaia@moodle.com>
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core\event;
+
+/**
+ * Test for content bank updated event.
+ *
+ * @package    core
+ * @category   test
+ * @copyright  2020 Amaia Anabitarte <amaia@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ * @coversDefaultClass \core\event\contentbank_content_updated
+ */
+class contentbank_content_updated_testcase extends \advanced_testcase {
+
+    /**
+     * Setup to ensure that fixtures are loaded.
+     */
+    public static function setUpBeforeClass() {
+        global $CFG;
+
+        require_once($CFG->dirroot . '/contentbank/tests/fixtures/testable_contenttype.php');
+        require_once($CFG->dirroot . '/contentbank/tests/fixtures/testable_content.php');
+    }
+
+    /**
+     * Test the content updated event.
+     *
+     * @covers ::create_from_record
+     */
+    public function test_content_updated() {
+
+        $this->resetAfterTest();
+        $this->setAdminUser();
+
+        // Save the system context.
+        $systemcontext = \context_system::instance();
+
+        // Create a content bank content.
+        $generator = $this->getDataGenerator()->get_plugin_generator('core_contentbank');
+        $contents = $generator->generate_contentbank_data('contenttype_testable', 1);
+        $content = array_shift($contents);
+
+        // Store the name before we change it.
+        $oldname = $content->get_name();
+
+        // Trigger and capture the event when renaming a content.
+        $sink = $this->redirectEvents();
+
+        $newname = "New name";
+        $content->set_name($newname);
+        $events = $sink->get_events();
+        $event = reset($events);
+
+        // Check that the event data is valid.
+        $this->assertInstanceOf('\core\event\contentbank_content_updated', $event);
+        $this->assertEquals($systemcontext, $event->get_context());
+    }
+}
diff --git a/lib/tests/event/contentbank_content_uploaded_test.php b/lib/tests/event/contentbank_content_uploaded_test.php
new file mode 100644 (file)
index 0000000..93e6d7d
--- /dev/null
@@ -0,0 +1,94 @@
+<?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/>.
+
+/**
+ * Content bank uploaded event tests.
+ *
+ * @package core
+ * @category test
+ * @copyright 2020 Amaia Anabitarte <amaia@moodle.com>
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core\event;
+
+use core_contentbank\contentbank;
+
+/**
+ * Test for content bank uploaded event.
+ *
+ * @package    core
+ * @category   test
+ * @copyright  2020 Amaia Anabitarte <amaia@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ * @coversDefaultClass \core\event\contentbank_content_uploaded
+ */
+class contentbank_content_uploaded_testcase extends \advanced_testcase {
+
+    /**
+     * Setup to ensure that fixtures are loaded.
+     */
+    public static function setUpBeforeClass() {
+        global $CFG;
+
+        require_once($CFG->dirroot . '/contentbank/tests/fixtures/testable_contenttype.php');
+        require_once($CFG->dirroot . '/contentbank/tests/fixtures/testable_content.php');
+    }
+
+    /**
+     * Test the content created event.
+     *
+     * @covers ::create_from_record
+     */
+    public function test_content_created() {
+        global $USER;
+
+        $this->resetAfterTest();
+        $this->setAdminUser();
+        $systemcontext = \context_system::instance();
+
+        // Create a dummy H5P file.
+        $dummyh5p = array(
+            'contextid' => $systemcontext->id,
+            'component' => 'contentbank',
+            'filearea' => 'public',
+            'itemid' => 1,
+            'filepath' => '/',
+            'filename' => 'dummy_h5p.h5p'
+        );
+        $fs = get_file_storage();
+        $dummyh5pfile = $fs->create_file_from_string($dummyh5p, 'Dummy H5Pcontent');
+
+        // Trigger and capture the event when creating content from a file.
+        $sink = $this->redirectEvents();
+        $cb = new contentbank();
+        $cb->create_content_from_file($systemcontext, $USER->id, $dummyh5pfile);
+
+        // Both uploaded and created events are raised.
+        $events = $sink->get_events();
+        $this->assertCount(2, $events);
+
+        // First the created content event has been raised.
+        $event = array_shift($events);
+        $this->assertInstanceOf('\core\event\contentbank_content_created', $event);
+        $this->assertEquals($systemcontext, $event->get_context());
+
+        // Second the uploaded content event has been raised.
+        $event = array_pop($events);
+        $this->assertInstanceOf('\core\event\contentbank_content_uploaded', $event);
+        $this->assertEquals($systemcontext, $event->get_context());
+    }
+}
diff --git a/lib/tests/event/contentbank_content_viewed_test.php b/lib/tests/event/contentbank_content_viewed_test.php
new file mode 100644 (file)
index 0000000..acc7e53
--- /dev/null
@@ -0,0 +1,79 @@
+<?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/>.
+
+/**
+ * Content bank viewed event tests.
+ *
+ * @package core
+ * @category test
+ * @copyright 2020 Amaia Anabitarte <amaia@moodle.com>
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core\event;
+
+/**
+ * Test for content bank viewed event.
+ *
+ * @package    core
+ * @category   test
+ * @copyright  2020 Amaia Anabitarte <amaia@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ * @coversDefaultClass \core\event\contentbank_content_viewed
+ */
+class contentbank_content_viewed_testcase extends \advanced_testcase {
+
+    /**
+     * Setup to ensure that fixtures are loaded.
+     */
+    public static function setUpBeforeClass() {
+        global $CFG;
+
+        require_once($CFG->dirroot . '/contentbank/tests/fixtures/testable_contenttype.php');
+        require_once($CFG->dirroot . '/contentbank/tests/fixtures/testable_content.php');
+    }
+
+    /**
+     * Test the content updated event.
+     *
+     * @covers ::create_from_record
+     */
+    public function test_content_updated() {
+
+        $this->resetAfterTest();
+        $this->setAdminUser();
+
+        // Save the system context.
+        $systemcontext = \context_system::instance();
+
+        // Create a content bank content.
+        $generator = $this->getDataGenerator()->get_plugin_generator('core_contentbank');
+        $contents = $generator->generate_contentbank_data('contenttype_testable', 1);
+        $content = array_shift($contents);
+
+        // Trigger and capture the content viewed event.
+        $sink = $this->redirectEvents();
+        $eventtotrigger = \core\event\contentbank_content_viewed::create_from_record($content->get_content());
+        $eventtotrigger->trigger();
+
+        $events = $sink->get_events();
+        $event = reset($events);
+
+        // Check that the event data is valid.
+        $this->assertInstanceOf('\core\event\contentbank_content_viewed', $event);
+        $this->assertEquals($systemcontext, $event->get_context());
+    }
+}
index 3142b26..59a2bcb 100644 (file)
@@ -2155,6 +2155,15 @@ class core_questionlib_testcase extends advanced_testcase {
             ['id9', 'id10'],
             ['id009', 'id010'],
             ['id999', 'id1000'],
+            ['0', '1'],
+            ['-1', '-2'],
+            ['01', '02'],
+            ['09', '10'],
+            ['1.0E+29', '1.0E+30'], // Idnumbers are strings, not floats.
+            ['1.0E-29', '1.0E-30'], // By the way, this is not a sensible idnumber!
+            ['10.1', '10.2'],
+            ['10.9', '10.10'],
+
         ];
     }
 
index 71c16be..9fc42ac 100644 (file)
Binary files a/media/player/videojs/amd/build/Youtube-lazy.min.js and b/media/player/videojs/amd/build/Youtube-lazy.min.js differ
index 4553449..b4d5671 100644 (file)
Binary files a/media/player/videojs/amd/build/Youtube-lazy.min.js.map and b/media/player/videojs/amd/build/Youtube-lazy.min.js.map differ
index 38921dd..aec8187 100644 (file)
Binary files a/media/player/videojs/amd/build/video-lazy.min.js and b/media/player/videojs/amd/build/video-lazy.min.js differ
index 25772f1..4581b63 100644 (file)
Binary files a/media/player/videojs/amd/build/video-lazy.min.js.map and b/media/player/videojs/amd/build/video-lazy.min.js.map differ
index 79f882e..f28a0d1 100644 (file)
Binary files a/media/player/videojs/amd/build/videojs-flash-lazy.min.js and b/media/player/videojs/amd/build/videojs-flash-lazy.min.js differ
index 0802abc..c61c73c 100644 (file)
Binary files a/media/player/videojs/amd/build/videojs-flash-lazy.min.js.map and b/media/player/videojs/amd/build/videojs-flash-lazy.min.js.map differ
index 1c8dba4..de221fe 100644 (file)
@@ -1,13 +1,17 @@
 /* The MIT License (MIT)
+
 Copyright (c) 2014-2015 Benoit Tremblay <trembl.ben@gmail.com>
+
 Permission is hereby granted, free of charge, to any person obtaining a copy
 of this software and associated documentation files (the "Software"), to deal
 in the Software without restriction, including without limitation the rights
 to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
 copies of the Software, and to permit persons to whom the Software is
 furnished to do so, subject to the following conditions:
+
 The above copyright notice and this permission notice shall be included in
 all copies or substantial portions of the Software.
+
 THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
@@ -363,7 +367,7 @@ THE SOFTWARE. */
         options.startSeconds = this.options_.start;
       }
       if (this.options_.end) {
-        options.endEnd = this.options_.end;
+        options.endSeconds = this.options_.end;
       }
       this.ytPlayer.loadVideoById(options);
     },
@@ -376,7 +380,7 @@ THE SOFTWARE. */
         options.startSeconds = this.options_.start;
       }
       if (this.options_.end) {
-        options.endEnd = this.options_.end;
+        options.endSeconds = this.options_.end;
       }
       this.ytPlayer.cueVideoById(options);
     },
@@ -736,6 +740,11 @@ THE SOFTWARE. */
     var loaded = false;
     var tag = document.createElement('script');
     var firstScriptTag = document.getElementsByTagName('script')[0];
+    if (!firstScriptTag) {
+      // when loaded in jest without jsdom setup it doesn't get any element.
+      // In jest it doesn't really make sense to do anything, because no one is watching youtube in jest
+      return;
+    }
     firstScriptTag.parentNode.insertBefore(tag, firstScriptTag);
     tag.onload = function () {
       if (!loaded) {
@@ -786,4 +795,4 @@ THE SOFTWARE. */
   } else {
     videojs.registerComponent('Youtube', Youtube);
   }
-}));
\ No newline at end of file
+}));
index b32a05d..9e57c6c 100644 (file)
@@ -1,6 +1,6 @@
 /**
  * @license
- * Video.js 7.6.5 <http://videojs.com/>
+ * Video.js 7.7.6 <http://videojs.com/>
  * Copyright Brightcove, Inc. <https://www.brightcove.com/>
  * Available under Apache License Version 2.0
  * <https://github.com/videojs/video.js/blob/master/LICENSE>
   typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory(require('global/window'), require('global/document')) :
   typeof define === 'function' && define.amd ? define(['./window', './document'], factory) :
   (global = global || self, global.videojs = factory(global.window, global.document));
-}(this, function (window$1, document) {
-  window$1 = window$1 && window$1.hasOwnProperty('default') ? window$1['default'] : window$1;
+}(this, function (window$3, document) { 'use strict';
+
+  window$3 = window$3 && window$3.hasOwnProperty('default') ? window$3['default'] : window$3;
   document = document && document.hasOwnProperty('default') ? document['default'] : document;
 
-  var version = "7.6.5";
+  var version = "7.7.6";
 
   /**
    * @file create-logger.js
       args.unshift(name + ':'); // Add a clone of the args at this point to history.
 
       if (history) {
-        history.push([].concat(args));
+        history.push([].concat(args)); // only store 1000 history entries
+
+        var splice = history.length - 1000;
+        history.splice(0, splice > 0 ? splice : 0);
       } // If there's no console then don't try to output messages, but they will
       // still be stored in history.
 
 
-      if (!window$1.console) {
+      if (!window$3.console) {
         return;
       } // Was setting these once outside of this function, but containing them
       // in the function makes it easier to test cases where console doesn't exist