Merge branch 'MDL-53779-master' of git://github.com/FMCorz/moodle
authorAndrew Nicols <andrew@nicols.co.uk>
Tue, 19 Apr 2016 02:32:56 +0000 (10:32 +0800)
committerAndrew Nicols <andrew@nicols.co.uk>
Tue, 19 Apr 2016 02:32:56 +0000 (10:32 +0800)
233 files changed:
admin/settings/server.php
admin/tool/log/store/standard/db/install.xml
admin/tool/log/store/standard/db/upgrade.php
admin/tool/log/store/standard/version.php
admin/tool/monitor/classes/notification_task.php
admin/tool/monitor/tests/eventobservers_test.php
admin/webservice/documentation.php
admin/webservice/forms.php
admin/webservice/testclient.php
availability/condition/grade/tests/behat/availability_grade.feature
blocks/course_overview/renderer.php
blocks/navigation/styles.css
cache/stores/memcached/addinstanceform.php
cache/stores/memcached/lang/en/cachestore_memcached.php
cache/stores/memcached/lib.php
cache/stores/memcached/tests/memcached_test.php
cohort/classes/output/cohortidnumber.php
cohort/classes/output/cohortname.php
completion/tests/behat/restrict_activity_by_grade.feature
completion/tests/behat/restrict_section_availability.feature
config-dist.php
course/classes/output/course_module_name.php
course/edit.php
course/externallib.php
course/format/lib.php
course/format/singleactivity/lib.php
course/lib.php
course/pending.php
course/renderer.php
course/tests/courselib_test.php
course/tests/externallib_test.php
grade/grading/form/guide/renderer.php
grade/grading/form/guide/tests/behat/edit_guide.feature
grade/grading/form/rubric/tests/behat/edit_rubric.feature
grade/grading/tests/behat/behat_grading.php
grade/report/singleview/tests/behat/bulk_insert_grades.feature
grade/tests/behat/grade_scales.feature
grade/tests/behat/grade_single_item_scales.feature
install/lang/ca/install.php
install/lang/cs/install.php
lang/en/admin.php
lang/en/cache.php
lang/en/moodle.php
lang/en/search.php
lang/en/tag.php
lib/ajax/service.php
lib/amd/build/form-autocomplete.min.js
lib/amd/build/form-course-selector.min.js
lib/amd/build/tag.min.js
lib/amd/build/tooltip.min.js [new file with mode: 0644]
lib/amd/src/form-autocomplete.js
lib/amd/src/form-course-selector.js
lib/amd/src/tag.js
lib/amd/src/tooltip.js [new file with mode: 0644]
lib/behat/behat_base.php
lib/behat/lib.php
lib/classes/task/manager.php
lib/db/caches.php
lib/db/services.php
lib/db/tag.php
lib/deprecatedlib.php
lib/editor/atto/yui/build/moodle-editor_atto-editor/moodle-editor_atto-editor-debug.js
lib/editor/atto/yui/build/moodle-editor_atto-editor/moodle-editor_atto-editor-min.js
lib/editor/atto/yui/build/moodle-editor_atto-editor/moodle-editor_atto-editor.js
lib/editor/atto/yui/src/editor/js/autosave.js
lib/external/externallib.php
lib/externallib.php
lib/filestorage/file_storage.php
lib/form/autocomplete.php
lib/form/course.php
lib/form/tests/behat/modgrade_validation.feature
lib/formslib.php
lib/moodlelib.php
lib/pagelib.php
lib/phpunit/bootstrap.php
lib/templates/form_autocomplete_selection.mustache
lib/tests/adhoc_task_test.php
lib/tests/behat/behat_forms.php
lib/tests/behat/behat_general.php
lib/tests/behat/behat_hooks.php
lib/tests/externallib_test.php
lib/tests/fixtures/unoconv-source.docx [new file with mode: 0644]
lib/tests/fixtures/unoconv-source.html [new file with mode: 0644]
lib/tests/unoconv_test.php [new file with mode: 0644]
lib/upgrade.txt
mod/assign/amd/build/grading_actions.min.js [new file with mode: 0644]
mod/assign/amd/build/grading_form_change_checker.min.js [new file with mode: 0644]
mod/assign/amd/build/grading_navigation.min.js [new file with mode: 0644]
mod/assign/amd/build/grading_navigation_user_info.min.js [new file with mode: 0644]
mod/assign/amd/build/grading_panel.min.js [new file with mode: 0644]
mod/assign/amd/build/grading_review_panel.min.js [new file with mode: 0644]
mod/assign/amd/build/participant_selector.min.js [new file with mode: 0644]
mod/assign/amd/src/grading_actions.js [new file with mode: 0644]
mod/assign/amd/src/grading_form_change_checker.js [new file with mode: 0644]
mod/assign/amd/src/grading_navigation.js [new file with mode: 0644]
mod/assign/amd/src/grading_navigation_user_info.js [new file with mode: 0644]
mod/assign/amd/src/grading_panel.js [new file with mode: 0644]
mod/assign/amd/src/grading_review_panel.js [new file with mode: 0644]
mod/assign/amd/src/participant_selector.js [new file with mode: 0644]
mod/assign/classes/output/grading_app.php [new file with mode: 0644]
mod/assign/db/services.php
mod/assign/externallib.php
mod/assign/feedback/editpdf/classes/document_services.php
mod/assign/feedback/editpdf/classes/event/observer.php [new file with mode: 0644]
mod/assign/feedback/editpdf/classes/renderer.php
mod/assign/feedback/editpdf/classes/task/convert_submissions.php [new file with mode: 0644]
mod/assign/feedback/editpdf/db/events.php [new file with mode: 0644]
mod/assign/feedback/editpdf/db/install.xml
mod/assign/feedback/editpdf/db/tasks.php [new file with mode: 0644]
mod/assign/feedback/editpdf/db/upgrade.php
mod/assign/feedback/editpdf/lang/en/assignfeedback_editpdf.php
mod/assign/feedback/editpdf/locallib.php
mod/assign/feedback/editpdf/styles.css
mod/assign/feedback/editpdf/tests/behat/annotate_pdf.feature
mod/assign/feedback/editpdf/tests/behat/behat_assignfeedback_editpdf.php
mod/assign/feedback/editpdf/tests/behat/group_annotations.feature
mod/assign/feedback/editpdf/version.php
mod/assign/feedback/editpdf/yui/build/moodle-assignfeedback_editpdf-editor/moodle-assignfeedback_editpdf-editor-debug.js
mod/assign/feedback/editpdf/yui/build/moodle-assignfeedback_editpdf-editor/moodle-assignfeedback_editpdf-editor-min.js
mod/assign/feedback/editpdf/yui/build/moodle-assignfeedback_editpdf-editor/moodle-assignfeedback_editpdf-editor.js
mod/assign/feedback/editpdf/yui/src/editor/js/editor.js
mod/assign/feedback/editpdf/yui/src/editor/js/globals.js
mod/assign/feedback/editpdf/yui/src/editor/meta/editor.json
mod/assign/feedbackplugin.php
mod/assign/gradingtable.php
mod/assign/lang/en/assign.php
mod/assign/lib.php
mod/assign/locallib.php
mod/assign/renderable.php
mod/assign/renderer.php
mod/assign/styles.css
mod/assign/templates/attempt_history_chooser.mustache [new file with mode: 0644]
mod/assign/templates/grading_actions.mustache [new file with mode: 0644]
mod/assign/templates/grading_app.mustache [new file with mode: 0644]
mod/assign/templates/grading_navigation.mustache [new file with mode: 0644]
mod/assign/templates/grading_navigation_no_users.mustache [new file with mode: 0644]
mod/assign/templates/grading_navigation_user_info.mustache [new file with mode: 0644]
mod/assign/templates/grading_navigation_user_selector.mustache [new file with mode: 0644]
mod/assign/templates/grading_navigation_user_summary.mustache [new file with mode: 0644]
mod/assign/templates/grading_panel.mustache [new file with mode: 0644]
mod/assign/templates/grading_save_in_progress.mustache [new file with mode: 0644]
mod/assign/templates/list_participant_user_summary.mustache [new file with mode: 0644]
mod/assign/templates/loading.mustache [new file with mode: 0644]
mod/assign/templates/popout_button.mustache [new file with mode: 0644]
mod/assign/templates/review_panel.mustache [new file with mode: 0644]
mod/assign/tests/base_test.php
mod/assign/tests/behat/allow_another_attempt.feature
mod/assign/tests/behat/comment_inline.feature
mod/assign/tests/behat/display_error_message_onbadformat.feature
mod/assign/tests/behat/display_grade.feature
mod/assign/tests/behat/edit_previous_feedback.feature
mod/assign/tests/behat/filter_by_marker.feature
mod/assign/tests/behat/grading_status.feature
mod/assign/tests/behat/grant_extension.feature
mod/assign/tests/behat/group_submission.feature
mod/assign/tests/behat/outcome_grading.feature
mod/assign/tests/behat/prevent_submission_changes.feature
mod/assign/tests/behat/quickgrading.feature
mod/assign/tests/behat/reopen_locked_submission.feature
mod/assign/tests/behat/rescale_grades.feature
mod/assign/tests/behat/steps_blind_marking.feature
mod/assign/tests/behat/submission_comments.feature
mod/assign/tests/behat/submit_without_group.feature
mod/assign/version.php
mod/feedback/analysis_course.php
mod/feedback/classes/templates_table.php [new file with mode: 0644]
mod/feedback/delete_template.php
mod/feedback/delete_template_form.php [deleted file]
mod/feedback/edit.php
mod/feedback/edit_form.php
mod/feedback/import.php
mod/feedback/lang/en/feedback.php
mod/feedback/styles.css
mod/feedback/tests/behat/behat_mod_feedback.php
mod/feedback/tests/behat/export_import.feature [new file with mode: 0644]
mod/feedback/tests/behat/templates.feature [new file with mode: 0644]
mod/feedback/tests/fixtures/testexport.xml [new file with mode: 0644]
mod/feedback/use_templ.php
mod/feedback/use_templ_form.php
mod/forum/externallib.php
mod/forum/lib.php
mod/forum/tests/externallib_test.php
mod/forum/tests/lib_test.php
mod/glossary/view.php
mod/lti/classes/plugininfo/ltisource.php
mod/lti/lib.php
mod/lti/locallib.php
mod/lti/mod_form.php
mod/lti/source/upgrade.txt [new file with mode: 0644]
mod/lti/tests/behat/addtool.feature [new file with mode: 0644]
mod/lti/tests/fixtures/tool_provider.html [new file with mode: 0644]
mod/upgrade.txt
mod/wiki/classes/external.php
mod/wiki/classes/search/collaborative_page.php [new file with mode: 0644]
mod/wiki/db/services.php
mod/wiki/lang/en/wiki.php
mod/wiki/locallib.php
mod/wiki/tests/behat/edit_tags.feature
mod/wiki/tests/externallib_test.php
mod/wiki/tests/search_test.php [new file with mode: 0644]
question/export_form.php
question/import_form.php
search/classes/engine.php
search/classes/manager.php
search/classes/output/form/search.php
search/engine/solr/classes/engine.php
search/engine/solr/classes/schema.php
search/engine/solr/lang/en/search_solr.php
search/engine/solr/setup_schema.php
search/engine/solr/tests/engine_test.php
search/index.php
search/tests/fixtures/testable_core_search.php
search/tests/manager_test.php
tag/classes/external.php
tag/classes/index_builder.php [new file with mode: 0644]
tag/classes/manage_table.php
tag/classes/tag.php
tag/lib.php
tag/manage.php
tag/tests/behat/edit_tag.feature
tag/tests/behat/standard_tags.feature
tag/tests/external_test.php
tag/tests/taglib_test.php
theme/bootstrapbase/less/moodle/core.less
theme/bootstrapbase/less/moodle/forms.less
theme/bootstrapbase/style/moodle.css
user/externallib.php
user/tests/externallib_test.php
version.php
webservice/lib.php
webservice/renderer.php
webservice/tests/lib_test.php
webservice/wsdoc.php

index 573ed4b..548031b 100644 (file)
@@ -12,6 +12,7 @@ $temp->add(new admin_setting_configexecutable('pathtodu', new lang_string('patht
 $temp->add(new admin_setting_configexecutable('aspellpath', new lang_string('aspellpath', 'admin'), new lang_string('edhelpaspellpath'), ''));
 $temp->add(new admin_setting_configexecutable('pathtodot', new lang_string('pathtodot', 'admin'), new lang_string('pathtodot_help', 'admin'), ''));
 $temp->add(new admin_setting_configexecutable('pathtogs', new lang_string('pathtogs', 'admin'), new lang_string('pathtogs_help', 'admin'), '/usr/bin/gs'));
+$temp->add(new admin_setting_configexecutable('pathtounoconv', new lang_string('pathtounoconv', 'admin'), new lang_string('pathtounoconv_help', 'admin'), '/usr/bin/unoconv'));
 $ADMIN->add('server', $temp);
 
 
index 9586c03..22d80d4 100644 (file)
@@ -30,6 +30,7 @@
       </FIELDS>
       <KEYS>
         <KEY NAME="primary" TYPE="primary" FIELDS="id"/>
+        <KEY NAME="contextid" TYPE="foreign" FIELDS="contextid" REFTABLE="context" REFFIELDS="id"/>
       </KEYS>
       <INDEXES>
         <INDEX NAME="timecreated" UNIQUE="false" FIELDS="timecreated"/>
index b844b62..4e23361 100644 (file)
@@ -25,7 +25,9 @@
 defined('MOODLE_INTERNAL') || die();
 
 function xmldb_logstore_standard_upgrade($oldversion) {
-    global $CFG;
+    global $CFG, $DB;
+
+    $dbman = $DB->get_manager();
 
     // Moodle v2.8.0 release upgrade line.
     // Put any upgrade step following this.
@@ -36,5 +38,20 @@ function xmldb_logstore_standard_upgrade($oldversion) {
     // Moodle v3.0.0 release upgrade line.
     // Put any upgrade step following this.
 
+    if ($oldversion < 2016041200) {
+        // This could take a long time. Unfortunately, no way to know how long, and no way to do progress, so setting for 1 hour.
+        upgrade_set_timeout(3600);
+
+        // Define key contextid (foreign) to be added to logstore_standard_log.
+        $table = new xmldb_table('logstore_standard_log');
+        $key = new xmldb_key('contextid', XMLDB_KEY_FOREIGN, array('contextid'), 'context', array('id'));
+
+        // Launch add key contextid.
+        $dbman->add_key($table, $key);
+
+        // Standard savepoint reached.
+        upgrade_plugin_savepoint(true, 2016041200, 'logstore', 'standard');
+    }
+
     return true;
 }
index 73c131e..2e482f4 100644 (file)
@@ -24,6 +24,6 @@
 
 defined('MOODLE_INTERNAL') || die();
 
-$plugin->version = 2015111600; // The current plugin version (Date: YYYYMMDDXX).
+$plugin->version = 2016041200; // The current plugin version (Date: YYYYMMDDXX).
 $plugin->requires = 2015111000; // Requires this Moodle version.
 $plugin->component = 'logstore_standard'; // Full name of the plugin (used for diagnostics).
index 150129c..6e36841 100644 (file)
@@ -82,15 +82,16 @@ class notification_task extends \core\task\adhoc_task {
 
         $template = $subscription->template;
         $template = $this->replace_placeholders($template, $subscription, $eventobj, $context);
+        $htmlmessage = format_text($template, $subscription->templateformat, array('context' => $context));
         $msgdata = new \stdClass();
         $msgdata->component         = 'tool_monitor'; // Your component name.
         $msgdata->name              = 'notification'; // This is the message name from messages.php.
         $msgdata->userfrom          = \core_user::get_noreply_user();
         $msgdata->userto            = $user;
         $msgdata->subject           = $subscription->get_name($context);
-        $msgdata->fullmessage       = format_text($template, $subscription->templateformat, array('context' => $context));
-        $msgdata->fullmessageformat = $subscription->templateformat;
-        $msgdata->fullmessagehtml   = format_text($template, $subscription->templateformat, array('context' => $context));
+        $msgdata->fullmessage       = html_to_text($htmlmessage);
+        $msgdata->fullmessageformat = FORMAT_PLAIN;
+        $msgdata->fullmessagehtml   = $htmlmessage;
         $msgdata->smallmessage      = '';
         $msgdata->notification      = 1; // This is only set to 0 for personal messages between users.
 
index 79356c6..9239e16 100644 (file)
@@ -467,7 +467,13 @@ class tool_monitor_eventobservers_testcase extends advanced_testcase {
         $rulerecord->eventname = '\mod_book\event\course_module_viewed';
         $rulerecord->cmid = $book->cmid;
         $rulerecord->frequency = 1;
-        $rulerecord->template = '{link} {modulelink} {rulename} {description} {eventname}';
+        $rulerecord->template = '## {link} ##
+
+* {modulelink}
+* __{rulename}__
+* {description}
+* {eventname}';
+        $rulerecord->templateformat = FORMAT_MARKDOWN;
 
         $rule = $toolgenerator->create_rule($rulerecord);
 
@@ -491,13 +497,21 @@ class tool_monitor_eventobservers_testcase extends advanced_testcase {
         $msg = array_pop($msgs);
 
         $modurl = new moodle_url('/mod/book/view.php', array('id' => $book->cmid));
-        $expectedmsg = $event->get_url()->out() . ' ' .
-                        $modurl->out()  . ' ' .
-                        $rule->get_name($context) . ' ' .
-                        $rule->get_description($context) . ' ' .
-                        $rule->get_event_name();
 
-        $this->assertEquals($expectedmsg, $msg->fullmessage);
+        $this->assertContains('<h2>'.$event->get_url()->out().'</h2>', $msg->fullmessagehtml);
+        $this->assertContains('<li>'.$modurl->out().'</li>', $msg->fullmessagehtml);
+        $this->assertContains('<li><strong>'.$rule->get_name($context).'</strong></li>', $msg->fullmessagehtml);
+        $this->assertContains('<li>'.$rule->get_description($context).'</li>', $msg->fullmessagehtml);
+        $this->assertContains('<li>'.$rule->get_event_name().'</li>', $msg->fullmessagehtml);
+
+        $this->assertEquals(FORMAT_PLAIN, $msg->fullmessageformat);
+        $this->assertNotContains('<h2>', $msg->fullmessage);
+        $this->assertNotContains('##', $msg->fullmessage);
+        $this->assertContains(strtoupper($event->get_url()->out()), $msg->fullmessage);
+        $this->assertContains('* '.$modurl->out(), $msg->fullmessage);
+        $this->assertContains('* '.strtoupper($rule->get_name($context)), $msg->fullmessage);
+        $this->assertContains('* '.$rule->get_description($context), $msg->fullmessage);
+        $this->assertContains('* '.$rule->get_event_name(), $msg->fullmessage);
     }
 
     /**
index 92a4dd8..9c727ea 100644 (file)
@@ -33,7 +33,7 @@ admin_externalpage_setup('webservicedocumentation');
 $functions = $DB->get_records('external_functions', array(), 'name');
 $functiondescs = array();
 foreach ($functions as $function) {
-    $functiondescs[$function->name] = external_function_info($function);
+    $functiondescs[$function->name] = external_api::external_function_info($function);
 }
 
 //display the documentation for all documented protocols,
index 88e543f..08ff31a 100644 (file)
@@ -196,7 +196,7 @@ class external_service_functions_form extends moodleform {
         //we add the descriptions to the functions
         foreach ($functions as $functionid => $functionname) {
             //retrieve full function information (including the description)
-            $function = external_function_info($functionname);
+            $function = external_api::external_function_info($functionname);
             if (empty($function->deprecated)) {
                 $functions[$functionid] = $function->name . ':' . $function->description;
             } else {
index d14dbb9..444e384 100644 (file)
@@ -49,7 +49,7 @@ admin_externalpage_setup('testclient');
 $allfunctions = $DB->get_records('external_functions', array(), 'name ASC');
 $functions = array();
 foreach ($allfunctions as $f) {
-    $finfo = external_function_info($f);
+    $finfo = external_api::external_function_info($f);
     if (!empty($finfo->testclientpath) and file_exists($CFG->dirroot.'/'.$finfo->testclientpath)) {
         //some plugins may want to have own test client forms
         include_once($CFG->dirroot.'/'.$finfo->testclientpath);
@@ -113,7 +113,7 @@ if ($mform->is_cancelled()) {
 
 } else if ($data = $mform->get_data()) {
 
-    $functioninfo = external_function_info($function);
+    $functioninfo = external_api::external_function_info($function);
 
     // first load lib of selected protocol
     require_once("$CFG->dirroot/webservice/$protocol/locallib.php");
index 9c250e1..e987ed3 100644 (file)
@@ -115,11 +115,13 @@ Feature: availability_grade
 
     # Give the assignment 40%.
     And I follow "A1"
-    And I follow "View/grade all submissions"
+    And I follow "View all submissions"
     # Pick the grade link in the row that has s@example.com in it.
-    And I click on "//a[contains(@href, 'action=grade') and ancestor::tr/td[normalize-space(.) = 's@example.com']]/img" "xpath_element"
+    And I click on "Grade" "link" in the "s@example.com" "table_row"
     And I set the field "Grade out of 100" to "40"
     And I click on "Save changes" "button"
+    And I press "Ok"
+    And I click on "Edit settings" "link"
 
     # Log back in as student.
     And I log out
index 8923322..1dad1ab 100644 (file)
@@ -325,7 +325,7 @@ class block_course_overview_renderer extends plugin_renderer_base {
      * @return string html string for welcome area.
      */
     public function welcome_area($msgcount) {
-        global $USER;
+        global $CFG, $USER;
         $output = $this->output->box_start('welcome_area');
 
         $picture = $this->output->user_picture($USER, array('size' => 75, 'class' => 'welcome_userpicture'));
@@ -334,16 +334,19 @@ class block_course_overview_renderer extends plugin_renderer_base {
         $output .= $this->output->box_start('welcome_message');
         $output .= $this->output->heading(get_string('welcome', 'block_course_overview', $USER->firstname));
 
-        $plural = 's';
-        if ($msgcount > 0) {
-            $output .= get_string('youhavemessages', 'block_course_overview', $msgcount);
-            if ($msgcount == 1) {
-                $plural = '';
+        if (!empty($CFG->messaging)) {
+            $plural = 's';
+            if ($msgcount > 0) {
+                $output .= get_string('youhavemessages', 'block_course_overview', $msgcount);
+                if ($msgcount == 1) {
+                    $plural = '';
+                }
+            } else {
+                $output .= get_string('youhavenomessages', 'block_course_overview');
             }
-        } else {
-            $output .= get_string('youhavenomessages', 'block_course_overview');
+            $output .= html_writer::link(new moodle_url('/message/index.php'),
+                    get_string('message'.$plural, 'block_course_overview'));
         }
-        $output .= html_writer::link(new moodle_url('/message/index.php'), get_string('message'.$plural, 'block_course_overview'));
         $output .= $this->output->box_end();
         $output .= $this->output->box('', 'flush');
         $output .= $this->output->box_end();
index e4e0392..c8aa881 100644 (file)
@@ -59,6 +59,7 @@
 .block_navigation .block_tree .tree_item.hasicon .item-content-wrap {
     display: inline-block;
     white-space: normal;
+    width: calc(100% - 21px);
 }
 
 .block_navigation .block_tree ul {
index 12bfbbc..e30c0bb 100644 (file)
@@ -42,6 +42,8 @@ class cachestore_memcached_addinstance_form extends cachestore_addinstance_form
      */
     protected function configuration_definition() {
         $form = $this->_form;
+        $version = phpversion('memcached');
+        $hasrequiredversion = ($version || version_compare($version, cachestore_memcached::REQUIRED_VERSION, '>='));
 
         $form->addElement('textarea', 'servers', get_string('servers', 'cachestore_memcached'), array('cols' => 75, 'rows' => 5));
         $form->addHelpButton('servers', 'servers', 'cachestore_memcached');
@@ -75,6 +77,15 @@ class cachestore_memcached_addinstance_form extends cachestore_addinstance_form
         $form->setDefault('bufferwrites', 0);
         $form->setType('bufferwrites', PARAM_BOOL);
 
+        if ($hasrequiredversion) {
+            // Only show this option if we have the required version of memcache extension installed.
+            // If it's not installed then this option does nothing, so there is no point in displaying it.
+            $form->addElement('selectyesno', 'isshared', get_string('isshared', 'cachestore_memcached'));
+            $form->addHelpButton('isshared', 'isshared', 'cachestore_memcached');
+            $form->setDefault('isshared', 0);
+            $form->setType('isshared', PARAM_BOOL);
+        }
+
         $form->addElement('header', 'clusteredheader', get_string('clustered', 'cachestore_memcached'));
 
         $form->addElement('checkbox', 'clustered', get_string('clustered', 'cachestore_memcached'));
@@ -86,6 +97,12 @@ class cachestore_memcached_addinstance_form extends cachestore_addinstance_form
         $form->addHelpButton('setservers', 'setservers', 'cachestore_memcached');
         $form->disabledIf('setservers', 'clustered');
         $form->setType('setservers', PARAM_RAW);
+
+        if (!$hasrequiredversion) {
+            $form->addElement('header', 'upgradenotice', get_string('notice', 'cachestore_memcached'));
+            $form->setExpanded('upgradenotice');
+            $form->addElement('html', nl2br(get_string('upgrade200recommended', 'cachestore_memcached')));
+        }
     }
 
     /**
index 27d079e..d9b849f 100644 (file)
@@ -46,6 +46,14 @@ $string['hash_fnv1_32'] = 'FNV1_32';
 $string['hash_fnv1a_32'] = 'FNV1A_32';
 $string['hash_hsieh'] = 'Hsieh';
 $string['hash_murmur'] = 'Murmur';
+$string['isshared'] = 'Shared cache';
+$string['isshared_help'] = "Is your memcached server also being used by other applications?
+
+If the cache is shared by other applications then each key will be deleted individually to ensure that only data owned by this application is purged (leaving external application cache data unchanged). This can result in reduced performance when purging the cache, depending on your server configuration.
+
+If you are running a dedicated cache for this application then the entire cache can safely be flushed without any risk of destroying another application's cache data. This should result in increased performance when purging the cache.
+";
+$string['notice'] = 'Notice';
 $string['pluginname'] = 'Memcached';
 $string['prefix'] = 'Prefix key';
 $string['prefix_help'] = 'This can be used to create a "domain" for your item keys allowing you to create multiple memcached stores on a single memcached installation. It cannot be longer than 16 characters in order to ensure key length issues are not encountered.';
@@ -88,3 +96,5 @@ $string['useserialiser'] = 'Use serialiser';
 $string['useserialiser_help'] = 'Specifies the serializer to use for serializing non-scalar values.
 The valid serializers are Memcached::SERIALIZER_PHP or Memcached::SERIALIZER_IGBINARY.
 The latter is supported only when memcached is configured with --enable-memcached-igbinary option and the igbinary extension is loaded.';
+$string['upgrade200recommended'] = 'We recommend you upgrade your Memcached PHP extension to version 2.0.0 or greater.
+The version of the Memcached PHP extension you are currently using does not provide the functionality Moodle uses to ensure a sandboxed cache. Until you upgrade we recommend you do not configure any other applications to use the same Memcached servers as Moodle is configured to use.';
index 5e8250d..9ecb706 100644 (file)
@@ -44,6 +44,12 @@ defined('MOODLE_INTERNAL') || die();
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
 class cachestore_memcached extends cache_store implements cache_is_configurable {
+
+    /**
+     * The minimum required version of memcached in order to use this store.
+     */
+    const REQUIRED_VERSION = '2.0.0';
+
     /**
      * The name of the store
      * @var store
@@ -104,6 +110,26 @@ class cachestore_memcached extends cache_store implements cache_is_configurable
      */
     protected $setconnections = array();
 
+    /**
+     * The prefix to use on all keys.
+     * @var string
+     */
+    protected $prefix = '';
+
+    /**
+     * True if Memcached::deleteMulti can be used, false otherwise.
+     * This required extension version 2.0.0 or greater.
+     * @var bool
+     */
+    protected $candeletemulti = false;
+
+    /**
+     * True if the memcached server is shared, false otherwise.
+     * This required extension version 2.0.0 or greater.
+     * @var bool
+     */
+    protected $isshared = false;
+
     /**
      * Constructs the store instance.
      *
@@ -171,7 +197,7 @@ class cachestore_memcached extends cache_store implements cache_is_configurable
 
         $this->options[Memcached::OPT_COMPRESSION] = $compression;
         $this->options[Memcached::OPT_SERIALIZER] = $serialiser;
-        $this->options[Memcached::OPT_PREFIX_KEY] = $prefix;
+        $this->options[Memcached::OPT_PREFIX_KEY] = $this->prefix = (string)$prefix;
         $this->options[Memcached::OPT_HASH] = $hashmethod;
         $this->options[Memcached::OPT_BUFFER_WRITES] = $bufferwrites;
 
@@ -196,6 +222,13 @@ class cachestore_memcached extends cache_store implements cache_is_configurable
             }
         }
 
+        if (isset($configuration['isshared'])) {
+            $this->isshared = $configuration['isshared'];
+        }
+
+        $version = phpversion('memcached');
+        $this->candeletemulti = ($version && version_compare($version, self::REQUIRED_VERSION, '>='));
+
         // Test the connection to the main connection.
         $this->isready = @$this->connection->set("ping", 'ping', 1);
     }
@@ -205,6 +238,7 @@ class cachestore_memcached extends cache_store implements cache_is_configurable
      *
      * Once this has been done the cache is all set to be used.
      *
+     * @throws coding_exception if the instance has already been initialised.
      * @param cache_definition $definition
      */
     public function initialise(cache_definition $definition) {
@@ -238,7 +272,7 @@ class cachestore_memcached extends cache_store implements cache_is_configurable
      * @return bool
      */
     public static function are_requirements_met() {
-        return class_exists('Memcached');
+        return extension_loaded('memcached') && class_exists('Memcached');
     }
 
     /**
@@ -411,6 +445,18 @@ class cachestore_memcached extends cache_store implements cache_is_configurable
      */
     protected function delete_many_connection(Memcached $connection, array $keys) {
         $count = 0;
+        if ($this->candeletemulti) {
+            // We can use deleteMulti, this is a bit faster yay!
+            $result = $connection->deleteMulti($keys);
+            foreach ($result as $key => $outcome) {
+                if ($outcome === true) {
+                    $count++;
+                }
+            }
+            return $count;
+        }
+
+        // They are running an older version of the php memcached extension.
         foreach ($keys as $key) {
             if ($connection->delete($key)) {
                 $count++;
@@ -426,18 +472,52 @@ class cachestore_memcached extends cache_store implements cache_is_configurable
      */
     public function purge() {
         if ($this->isready) {
+            // Only use delete multi if we have the correct extension installed and if the memcached
+            // server is shared (flushing the cache is quicker otherwise).
+            $candeletemulti = ($this->candeletemulti && $this->isshared);
+
             if ($this->clustered) {
                 foreach ($this->setconnections as $connection) {
-                    $connection->flush();
+                    if ($candeletemulti) {
+                        $keys = self::get_prefixed_keys($connection, $this->prefix);
+                        $connection->deleteMulti($keys);
+                    } else {
+                        // Oh damn, this isn't multi-site safe.
+                        $connection->flush();
+                    }
                 }
+            } else if ($candeletemulti) {
+                $keys = self::get_prefixed_keys($this->connection, $this->prefix);
+                $this->connection->deleteMulti($keys);
             } else {
+                // Oh damn, this isn't multi-site safe.
                 $this->connection->flush();
             }
         }
-
+        // It never fails. Ever.
         return true;
     }
 
+    /**
+     * Returns all of the keys in the given connection that belong to this cache store instance.
+     *
+     * Requires php memcached extension version 2.0.0 or greater.
+     *
+     * @param Memcached $connection
+     * @param string $prefix
+     * @return array
+     */
+    protected static function get_prefixed_keys(Memcached $connection, $prefix) {
+        $keys = array();
+        $start = strlen($prefix);
+        foreach ($connection->getAllKeys() as $key) {
+            if (strpos($key, $prefix) === 0) {
+                $keys[] = substr($key, $start);
+            }
+        }
+        return $keys;
+    }
+
     /**
      * Gets an array of options to use as the serialiser.
      * @return array
@@ -514,6 +594,11 @@ class cachestore_memcached extends cache_store implements cache_is_configurable
             }
         }
 
+        $isshared = false;
+        if (isset($data->isshared)) {
+            $isshared = $data->isshared;
+        }
+
         return array(
             'servers' => $servers,
             'compression' => $data->compression,
@@ -522,7 +607,8 @@ class cachestore_memcached extends cache_store implements cache_is_configurable
             'hash' => $data->hash,
             'bufferwrites' => $data->bufferwrites,
             'clustered' => $clustered,
-            'setservers' => $setservers
+            'setservers' => $setservers,
+            'isshared' => $isshared
         );
     }
 
@@ -566,6 +652,9 @@ class cachestore_memcached extends cache_store implements cache_is_configurable
             }
             $data['setservers'] = join("\n", $servers);
         }
+        if (isset($config['isshared'])) {
+            $data['isshared'] = $config['isshared'];
+        }
         $editform->set_data($data);
     }
 
@@ -585,6 +674,8 @@ class cachestore_memcached extends cache_store implements cache_is_configurable
                 $connection->addServers($this->servers);
             }
         }
+        // We have to flush here to be sure we are completely cleaned up.
+        // Bad for performance but this is incredibly rare.
         @$connection->flush();
         unset($connection);
         unset($this->connection);
index 506e583..6a58844 100644 (file)
@@ -54,6 +54,10 @@ class cachestore_memcached_test extends cachestore_tests {
      * Tests the valid keys to ensure they work.
      */
     public function test_valid_keys() {
+        if (!cachestore_memcached::are_requirements_met() || !defined('TEST_CACHESTORE_MEMCACHED_TESTSERVERS')) {
+            $this->markTestSkipped('Could not test cachestore_memcached. Requirements are not met.');
+        }
+
         $this->resetAfterTest(true);
 
         $definition = cache_definition::load_adhoc(cache_store::MODE_APPLICATION, 'cachestore_memcached', 'phpunit_test');
@@ -139,16 +143,16 @@ class cachestore_memcached_test extends cachestore_tests {
      * Tests the clustering feature.
      */
     public function test_clustered() {
-        $this->resetAfterTest(true);
-
-        if (!defined('TEST_CACHESTORE_MEMCACHED_TESTSERVERS')) {
-            $this->markTestSkipped();
+        if (!cachestore_memcached::are_requirements_met() || !defined('TEST_CACHESTORE_MEMCACHED_TESTSERVERS')) {
+            $this->markTestSkipped('Could not test cachestore_memcached. Requirements are not met.');
         }
 
+        $this->resetAfterTest(true);
+
         $testservers = explode("\n", trim(TEST_CACHESTORE_MEMCACHED_TESTSERVERS));
 
         if (count($testservers) < 2) {
-            $this->markTestSkipped();
+            $this->markTestSkipped('Could not test clustered memcached, there are not enough test servers defined.');
         }
 
         // Use the first server as our primary.
@@ -270,4 +274,138 @@ class cachestore_memcached_test extends cachestore_tests {
             }
         }
     }
+
+    /**
+     * Tests that memcached cache store doesn't just flush everything and instead deletes only what belongs to it
+     * when it is marked as a shared cache.
+     */
+    public function test_multi_use_compatibility() {
+        if (!cachestore_memcached::are_requirements_met() || !defined('TEST_CACHESTORE_MEMCACHED_TESTSERVERS')) {
+            $this->markTestSkipped('Could not test cachestore_memcached. Requirements are not met.');
+        }
+
+        $definition = cache_definition::load_adhoc(cache_store::MODE_APPLICATION, 'cachestore_memcached', 'phpunit_test');
+        $cachestore = $this->create_test_cache_with_config($definition, array('isshared' => true));
+        $connection = new Memcached(crc32(__METHOD__));
+        $connection->addServers($this->get_servers(TEST_CACHESTORE_MEMCACHED_TESTSERVERS));
+        $connection->setOptions(array(
+            Memcached::OPT_COMPRESSION => true,
+            Memcached::OPT_SERIALIZER => Memcached::SERIALIZER_PHP,
+            Memcached::OPT_PREFIX_KEY => 'phpunit_',
+            Memcached::OPT_BUFFER_WRITES => false
+        ));
+
+        // We must flush first to make sure nothing is there.
+        $connection->flush();
+
+        // Test the cachestore.
+        $this->assertFalse($cachestore->get('test'));
+        $this->assertTrue($cachestore->set('test', 'cachestore'));
+        $this->assertSame('cachestore', $cachestore->get('test'));
+
+        // Test the connection.
+        $this->assertFalse($connection->get('test'));
+        $this->assertEquals(Memcached::RES_NOTFOUND, $connection->getResultCode());
+        $this->assertTrue($connection->set('test', 'connection'));
+        $this->assertSame('connection', $connection->get('test'));
+
+        // Test both again and make sure the values are correct.
+        $this->assertSame('cachestore', $cachestore->get('test'));
+        $this->assertSame('connection', $connection->get('test'));
+
+        // Purge the cachestore and check the connection was not purged.
+        $this->assertTrue($cachestore->purge());
+        $this->assertFalse($cachestore->get('test'));
+        $this->assertSame('connection', $connection->get('test'));
+    }
+
+    /**
+     * Tests that memcached cache store flushes entire cache when it is using a dedicated cache.
+     */
+    public function test_dedicated_cache() {
+        if (!cachestore_memcached::are_requirements_met() || !defined('TEST_CACHESTORE_MEMCACHED_TESTSERVERS')) {
+            $this->markTestSkipped('Could not test cachestore_memcached. Requirements are not met.');
+        }
+
+        $definition = cache_definition::load_adhoc(cache_store::MODE_APPLICATION, 'cachestore_memcached', 'phpunit_test');
+        $cachestore = $this->create_test_cache_with_config($definition, array('isshared' => false));
+        $connection = new Memcached(crc32(__METHOD__));
+        $connection->addServers($this->get_servers(TEST_CACHESTORE_MEMCACHED_TESTSERVERS));
+        $connection->setOptions(array(
+            Memcached::OPT_COMPRESSION => true,
+            Memcached::OPT_SERIALIZER => Memcached::SERIALIZER_PHP,
+            Memcached::OPT_PREFIX_KEY => 'phpunit_',
+            Memcached::OPT_BUFFER_WRITES => false
+        ));
+
+        // We must flush first to make sure nothing is there.
+        $connection->flush();
+
+        // Test the cachestore.
+        $this->assertFalse($cachestore->get('test'));
+        $this->assertTrue($cachestore->set('test', 'cachestore'));
+        $this->assertSame('cachestore', $cachestore->get('test'));
+
+        // Test the connection.
+        $this->assertFalse($connection->get('test'));
+        $this->assertEquals(Memcached::RES_NOTFOUND, $connection->getResultCode());
+        $this->assertTrue($connection->set('test', 'connection'));
+        $this->assertSame('connection', $connection->get('test'));
+
+        // Test both again and make sure the values are correct.
+        $this->assertSame('cachestore', $cachestore->get('test'));
+        $this->assertSame('connection', $connection->get('test'));
+
+        // Purge the cachestore and check the connection was also purged.
+        $this->assertTrue($cachestore->purge());
+        $this->assertFalse($cachestore->get('test'));
+        $this->assertFalse($connection->get('test'));
+    }
+
+    /**
+     * Given a server string this returns an array of servers.
+     *
+     * @param string $serverstring
+     * @return array
+     */
+    public function get_servers($serverstring) {
+        $servers = array();
+        foreach (explode("\n", $serverstring) as $server) {
+            if (!is_array($server)) {
+                $server = explode(':', $server, 3);
+            }
+            if (!array_key_exists(1, $server)) {
+                $server[1] = 11211;
+                $server[2] = 100;
+            } else if (!array_key_exists(2, $server)) {
+                $server[2] = 100;
+            }
+            $servers[] = $server;
+        }
+        return $servers;
+    }
+
+    /**
+     * Creates a test instance for unit tests.
+     * @param cache_definition $definition
+     * @param array $configuration
+     * @return null|cachestore_memcached
+     */
+    private function create_test_cache_with_config(cache_definition $definition, $configuration = array()) {
+        $class = $this->get_class_name();
+
+        if (!$class::are_requirements_met()) {
+            return null;
+        }
+        if (!defined('TEST_CACHESTORE_MEMCACHED_TESTSERVERS')) {
+            return null;
+        }
+
+        $configuration['servers'] = explode("\n", TEST_CACHESTORE_MEMCACHED_TESTSERVERS);
+
+        $store = new $class('Test memcached', $configuration);
+        $store->initialise($definition);
+
+        return $store;
+    }
 }
index 5628917..454fdde 100644 (file)
@@ -61,6 +61,7 @@ class cohortidnumber extends \core\output\inplace_editable {
         global $DB;
         $cohort = $DB->get_record('cohort', array('id' => $cohortid), '*', MUST_EXIST);
         $cohortcontext = \context::instance_by_id($cohort->contextid);
+        \external_api::validate_context($cohortcontext);
         require_capability('moodle/cohort:manage', $cohortcontext);
         $record = (object)array('id' => $cohort->id, 'idnumber' => $newvalue, 'contextid' => $cohort->contextid);
         cohort_update_cohort($record);
index 7c74809..7ac5950 100644 (file)
@@ -61,6 +61,7 @@ class cohortname extends \core\output\inplace_editable {
         global $DB;
         $cohort = $DB->get_record('cohort', array('id' => $cohortid), '*', MUST_EXIST);
         $cohortcontext = \context::instance_by_id($cohort->contextid);
+        \external_api::validate_context($cohortcontext);
         require_capability('moodle/cohort:manage', $cohortcontext);
         $newvalue = clean_param($newvalue, PARAM_TEXT);
         if (strval($newvalue) !== '') {
index 27fa423..89ef521 100644 (file)
@@ -56,11 +56,13 @@ Feature: Restrict activity availability through grade conditions
     And I am on site homepage
     And I follow "Course 1"
     And I follow "Grade assignment"
-    And I follow "View/grade all submissions"
-    And I click on "Grade Student First" "link" in the "Student First" "table_row"
+    And I follow "View all submissions"
+    And I click on "Grade" "link" in the "Student First" "table_row"
     And I set the following fields to these values:
       | Grade | 21 |
     And I press "Save changes"
+    And I press "Ok"
+    And I follow "Edit settings"
     And I log out
     And I log in as "student1"
     And I am on site homepage
index 3a74723..7e0eeec 100644 (file)
@@ -93,11 +93,13 @@ Feature: Restrict sections availability through completion or grade conditions
     And I am on site homepage
     And I follow "Course 1"
     And I follow "Grade assignment"
-    And I follow "View/grade all submissions"
-    And I click on "Grade Student First" "link" in the "Student First" "table_row"
+    And I follow "View all submissions"
+    And I click on "Grade" "link" in the "Student First" "table_row"
     And I set the following fields to these values:
       | Grade | 21 |
     And I press "Save changes"
+    And I press "Ok"
+    And I follow "Edit settings"
     And I log out
     And I log in as "student1"
     And I am on site homepage
index f4167a5..30d8c15 100644 (file)
@@ -840,6 +840,12 @@ $CFG->admin = 'admin';
 // Note that, for now, this only used by the profiling features
 // (Development->Profiling) built into Moodle.
 //      $CFG->pathtodot = '';
+//
+// Path to unoconv.
+// Probably something like /usr/bin/unoconv. Used as a fallback to convert between document formats.
+// Unoconv is used convert between file formats supported by LibreOffice.
+// Use a recent version of unoconv ( >= 0.7 ), older versions have trouble running from a webserver.
+//      $CFG->pathtounoconv = '';
 
 //=========================================================================
 // ALL DONE!  To continue installation, visit your main page with a browser
index e286e55..5602b21 100644 (file)
@@ -86,15 +86,15 @@ class course_module_name extends \core\output\inplace_editable {
      * @return static
      */
     public static function update($itemid, $newvalue) {
-        list($course, $cm) = get_course_and_cm_from_cmid($itemid);
-        $context = context_module::instance($cm->id);
+        $context = context_module::instance($itemid);
         // Check access.
-        require_login($course, false, $cm, true, true);
+        \external_api::validate_context($context);
         require_capability('moodle/course:manageactivities', $context);
         // Update value.
-        set_coursemodule_name($cm->id, $newvalue);
+        set_coursemodule_name($itemid, $newvalue);
+        $coursemodulerecord = get_coursemodule_from_id('', $itemid, 0, false, MUST_EXIST);
         // Return instance.
-        $cm = get_fast_modinfo($course)->get_cm($cm->id);
+        $cm = get_fast_modinfo($coursemodulerecord->course)->get_cm($itemid);
         return new static($cm, true);
     }
 }
index d79c0d6..c833cca 100644 (file)
@@ -55,6 +55,9 @@ if ($returnto === 'url' && confirm_sesskey() && $returnurl) {
             case 'topcat':
                 $returnurl = new moodle_url($CFG->wwwroot . '/course/');
                 break;
+            case 'pending':
+                $returnurl = new moodle_url($CFG->wwwroot . '/course/pending.php');
+                break;
         }
     }
 }
index 6214828..7c5d975 100644 (file)
@@ -2139,7 +2139,8 @@ class core_course_external extends external_api {
                 'requiredcapabilities' => new external_multiple_structure(
                     new external_value(PARAM_CAPABILITY, 'Capability string used to filter courses by permission'),
                     VALUE_OPTIONAL
-                )
+                ),
+                'limittoenrolled' => new external_value(PARAM_BOOL, 'limit to enrolled courses', VALUE_DEFAULT, 0),
             )
         );
     }
@@ -2152,6 +2153,7 @@ class core_course_external extends external_api {
      * @param int $page             Page number (for pagination)
      * @param int $perpage          Items per page
      * @param array $requiredcapabilities Optional list of required capabilities (used to filter the list).
+     * @param int $limittoenrolled  Limit to only enrolled courses
      * @return array of course objects and warnings
      * @since Moodle 3.0
      * @throws moodle_exception
@@ -2160,7 +2162,8 @@ class core_course_external extends external_api {
                                           $criteriavalue,
                                           $page=0,
                                           $perpage=0,
-                                          $requiredcapabilities=array()) {
+                                          $requiredcapabilities=array(),
+                                          $limittoenrolled=0) {
         global $CFG;
         require_once($CFG->libdir . '/coursecatlib.php');
 
@@ -2207,10 +2210,22 @@ class core_course_external extends external_api {
         $courses = coursecat::search_courses($searchcriteria, $options, $params['requiredcapabilities']);
         $totalcount = coursecat::search_courses_count($searchcriteria, $options, $params['requiredcapabilities']);
 
+        if (!empty($limittoenrolled)) {
+            // Get the courses where the current user has access.
+            $enrolled = enrol_get_my_courses(array('id', 'cacherev'));
+        }
+
         $finalcourses = array();
         $categoriescache = array();
 
         foreach ($courses as $course) {
+            if (!empty($limittoenrolled)) {
+                // Filter out not enrolled courses.
+                if (!isset($enrolled[$course->id])) {
+                    $totalcount--;
+                    continue;
+                }
+            }
 
             $coursecontext = context_course::instance($course->id);
 
index 8a4078c..208c188 100644 (file)
@@ -1089,8 +1089,8 @@ abstract class format_base {
      */
     public function inplace_editable_update_section_name($section, $itemtype, $newvalue) {
         if ($itemtype === 'sectionname' || $itemtype === 'sectionnamenl') {
-            require_login($section->course, false, null, true, true);
             $context = context_course::instance($section->course);
+            external_api::validate_context($context);
             require_capability('moodle/course:update', $context);
 
             $newtitle = clean_param($newvalue, PARAM_TEXT);
index 813e2c6..f803e59 100644 (file)
@@ -341,7 +341,9 @@ class format_singleactivity extends format_base {
     }
 
     /**
-     * Checks if the activity type requires subtypes.
+     * Checks if the activity type has multiple items in the activity chooser.
+     * This may happen as a result of defining callback modulename_get_shortcuts()
+     * or [deprecated] modulename_get_types() - TODO MDL-53697 remove this line.
      *
      * @return bool|null (null if the check is not possible)
      */
@@ -349,7 +351,13 @@ class format_singleactivity extends format_base {
         if (!($modname = $this->get_activitytype())) {
             return null;
         }
-        return component_callback('mod_' . $modname, 'get_types', array(), MOD_SUBTYPE_NO_CHILDREN) !== MOD_SUBTYPE_NO_CHILDREN;
+        $metadata = get_module_metadata($this->get_course(), self::get_supported_activities());
+        foreach ($metadata as $key => $moduledata) {
+            if (preg_match('/^'.$modname.':/', $key)) {
+                return true;
+            }
+        }
+        return false;
     }
 
     /**
@@ -399,7 +407,7 @@ class format_singleactivity extends format_base {
                 if ($this->can_add_activity()) {
                     // This is a user who has capability to create an activity.
                     if ($this->activity_has_subtypes()) {
-                        // Activity that requires subtype can not be added automatically.
+                        // Activity has multiple items in the activity chooser, it can not be added automatically.
                         if (optional_param('addactivity', 0, PARAM_INT)) {
                             return;
                         } else {
index b3e96ab..9307e3c 100644 (file)
@@ -1226,7 +1226,7 @@ function set_section_visible($courseid, $sectionnumber, $visibility) {
  * module
  */
 function get_module_metadata($course, $modnames, $sectionreturn = null) {
-    global $CFG, $OUTPUT;
+    global $OUTPUT;
 
     // get_module_metadata will be called once per section on the page and courses may show
     // different modules to one another
@@ -1246,82 +1246,107 @@ function get_module_metadata($course, $modnames, $sectionreturn = null) {
         }
         if (isset($modlist[$course->id][$modname])) {
             // This module is already cached
-            $return[$modname] = $modlist[$course->id][$modname];
+            $return += $modlist[$course->id][$modname];
             continue;
         }
+        $modlist[$course->id][$modname] = array();
+
+        // Create an object for a default representation of this module type in the activity chooser. It will be used
+        // if module does not implement callback get_shortcuts() and it will also be passed to the callback if it exists.
+        $defaultmodule = new stdClass();
+        $defaultmodule->title = $modnamestr;
+        $defaultmodule->name = $modname;
+        $defaultmodule->link = new moodle_url($urlbase, array('add' => $modname));
+        $defaultmodule->icon = $OUTPUT->pix_icon('icon', '', $defaultmodule->name, array('class' => 'icon'));
+        $sm = get_string_manager();
+        if ($sm->string_exists('modulename_help', $modname)) {
+            $defaultmodule->help = get_string('modulename_help', $modname);
+            if ($sm->string_exists('modulename_link', $modname)) {  // Link to further info in Moodle docs.
+                $link = get_string('modulename_link', $modname);
+                $linktext = get_string('morehelp');
+                $defaultmodule->help .= html_writer::tag('div',
+                    $OUTPUT->doc_link($link, $linktext, true), array('class' => 'helpdoclink'));
+            }
+        }
+        $defaultmodule->archetype = plugin_supports('mod', $modname, FEATURE_MOD_ARCHETYPE, MOD_ARCHETYPE_OTHER);
 
-        // Include the module lib
-        $libfile = "$CFG->dirroot/mod/$modname/lib.php";
-        if (!file_exists($libfile)) {
+        // Legacy support for callback get_types() - do not use any more, use get_shortcuts() instead!
+        $typescallbackexists = component_callback_exists($modname, 'get_types');
+
+        // Each module can implement callback modulename_get_shortcuts() in its lib.php and return the list
+        // of elements to be added to activity chooser.
+        $items = component_callback($modname, 'get_shortcuts', array($defaultmodule), null);
+        if ($items !== null) {
+            foreach ($items as $item) {
+                // Add all items to the return array. All items must have different links, use them as a key in the return array.
+                if (!isset($item->archetype)) {
+                    $item->archetype = $defaultmodule->archetype;
+                }
+                if (!isset($item->icon)) {
+                    $item->icon = $defaultmodule->icon;
+                }
+                // If plugin returned the only one item with the same link as default item - cache it as $modname,
+                // otherwise append the link url to the module name.
+                $item->name = (count($items) == 1 &&
+                    $item->link->out() === $defaultmodule->link->out()) ? $modname : $modname . ':' . $item->link;
+                $modlist[$course->id][$modname][$item->name] = $item;
+            }
+            $return += $modlist[$course->id][$modname];
+            if ($typescallbackexists) {
+                debugging('Both callbacks get_shortcuts() and get_types() are found in module ' . $modname .
+                    '. Callback get_types() will be completely ignored', DEBUG_DEVELOPER);
+            }
+            // If get_shortcuts() callback is defined, the default module action is not added.
+            // It is a responsibility of the callback to add it to the return value unless it is not needed.
             continue;
         }
-        include_once($libfile);
 
-        // NOTE: this is legacy stuff, module subtypes are very strongly discouraged!!
-        $gettypesfunc =  $modname.'_get_types';
-        $types = MOD_SUBTYPE_NO_CHILDREN;
-        if (function_exists($gettypesfunc)) {
-            $types = $gettypesfunc();
+        if ($typescallbackexists) {
+            debugging('Callback get_types() is found in module ' . $modname . ', this functionality is deprecated, ' .
+                'please use callback get_shortcuts() instead', DEBUG_DEVELOPER);
         }
+        $types = component_callback($modname, 'get_types', array(), MOD_SUBTYPE_NO_CHILDREN);
         if ($types !== MOD_SUBTYPE_NO_CHILDREN) {
+            // Legacy support for deprecated callback get_types(). To be removed in Moodle 3.5. TODO MDL-53697.
             if (is_array($types) && count($types) > 0) {
-                $group = new stdClass();
-                $group->name = $modname;
-                $group->icon = $OUTPUT->pix_icon('icon', '', $modname, array('class' => 'icon'));
+                $grouptitle = $modnamestr;
+                $icon = $OUTPUT->pix_icon('icon', '', $modname, array('class' => 'icon'));
                 foreach($types as $type) {
                     if ($type->typestr === '--') {
                         continue;
                     }
                     if (strpos($type->typestr, '--') === 0) {
-                        $group->title = str_replace('--', '', $type->typestr);
+                        $grouptitle = str_replace('--', '', $type->typestr);
                         continue;
                     }
-                    // Set the Sub Type metadata
+                    // Set the Sub Type metadata.
                     $subtype = new stdClass();
-                    $subtype->title = $type->typestr;
+                    $subtype->title = get_string('activitytypetitle', '',
+                        (object)['activity' => $grouptitle, 'type' => $type->typestr]);
                     $subtype->type = str_replace('&amp;', '&', $type->type);
-                    $subtype->name = preg_replace('/.*type=/', '', $subtype->type);
+                    $typename = preg_replace('/.*type=/', '', $subtype->type);
                     $subtype->archetype = $type->modclass;
 
-                    // The group archetype should match the subtype archetypes and all subtypes
-                    // should have the same archetype
-                    $group->archetype = $subtype->archetype;
-
                     if (!empty($type->help)) {
                         $subtype->help = $type->help;
                     } else if (get_string_manager()->string_exists('help' . $subtype->name, $modname)) {
                         $subtype->help = get_string('help' . $subtype->name, $modname);
                     }
-                    $subtype->link = new moodle_url($urlbase, array('add' => $modname, 'type' => $subtype->name));
-                    $group->types[] = $subtype;
+                    $subtype->link = new moodle_url($urlbase, array('add' => $modname, 'type' => $typename));
+                    $subtype->name = $modname . ':' . $subtype->link;
+                    $subtype->icon = $icon;
+                    $modlist[$course->id][$modname][$subtype->name] = $subtype;
                 }
-                $modlist[$course->id][$modname] = $group;
+                $return += $modlist[$course->id][$modname];
             }
         } else {
-            $module = new stdClass();
-            $module->title = $modnamestr;
-            $module->name = $modname;
-            $module->link = new moodle_url($urlbase, array('add' => $modname));
-            $module->icon = $OUTPUT->pix_icon('icon', '', $module->name, array('class' => 'icon'));
-            $sm = get_string_manager();
-            if ($sm->string_exists('modulename_help', $modname)) {
-                $module->help = get_string('modulename_help', $modname);
-                if ($sm->string_exists('modulename_link', $modname)) {  // Link to further info in Moodle docs
-                    $link = get_string('modulename_link', $modname);
-                    $linktext = get_string('morehelp');
-                    $module->help .= html_writer::tag('div', $OUTPUT->doc_link($link, $linktext, true), array('class' => 'helpdoclink'));
-                }
-            }
-            $module->archetype = plugin_supports('mod', $modname, FEATURE_MOD_ARCHETYPE, MOD_ARCHETYPE_OTHER);
-            $modlist[$course->id][$modname] = $module;
-        }
-        if (isset($modlist[$course->id][$modname])) {
-            $return[$modname] = $modlist[$course->id][$modname];
-        } else {
-            debugging("Invalid module metadata configuration for {$modname}");
+            // Neither get_shortcuts() nor get_types() callbacks found, use the default item for the activity chooser.
+            $modlist[$course->id][$modname][$modname] = $defaultmodule;
+            $return[$modname] = $defaultmodule;
         }
     }
 
+    core_collator::asort_objects_by_property($return, 'title');
     return $return;
 }
 
@@ -3917,3 +3942,109 @@ function core_course_inplace_editable($itemtype, $itemid, $newvalue) {
         return \core_course\output\course_module_name::update($itemid, $newvalue);
     }
 }
+
+/**
+ * Returns course modules tagged with a specified tag ready for output on tag/index.php page
+ *
+ * This is a callback used by the tag area core/course_modules to search for course modules
+ * tagged with a specific tag.
+ *
+ * @param core_tag_tag $tag
+ * @param bool $exclusivemode if set to true it means that no other entities tagged with this tag
+ *             are displayed on the page and the per-page limit may be bigger
+ * @param int $fromcontextid context id where the link was displayed, may be used by callbacks
+ *            to display items in the same context first
+ * @param int $contextid context id where to search for records
+ * @param bool $recursivecontext search in subcontexts as well
+ * @param int $page 0-based number of page being displayed
+ * @return \core_tag\output\tagindex
+ */
+function course_get_tagged_course_modules($tag, $exclusivemode = false, $fromcontextid = 0, $contextid = 0,
+                                          $recursivecontext = 1, $page = 0) {
+    global $OUTPUT;
+    $perpage = $exclusivemode ? 20 : 5;
+
+    // Build select query.
+    $ctxselect = context_helper::get_preload_record_columns_sql('ctx');
+    $query = "SELECT cm.id AS cmid, c.id AS courseid, $ctxselect
+                FROM {course_modules} cm
+                JOIN {tag_instance} tt ON cm.id = tt.itemid
+                JOIN {course} c ON cm.course = c.id
+                JOIN {context} ctx ON ctx.instanceid = cm.id AND ctx.contextlevel = :coursemodulecontextlevel
+               WHERE tt.itemtype = :itemtype AND tt.tagid = :tagid AND tt.component = :component
+                AND c.id %COURSEFILTER% AND cm.id %ITEMFILTER%";
+
+    $params = array('itemtype' => 'course_modules', 'tagid' => $tag->id, 'component' => 'core',
+        'coursemodulecontextlevel' => CONTEXT_MODULE);
+    if ($contextid) {
+        $context = context::instance_by_id($contextid);
+        $query .= $recursivecontext ? ' AND (ctx.id = :contextid OR ctx.path LIKE :path)' : ' AND ctx.id = :contextid';
+        $params['contextid'] = $context->id;
+        $params['path'] = $context->path.'/%';
+    }
+
+    $query .= ' ORDER BY';
+    if ($fromcontextid) {
+        // In order-clause specify that modules from inside "fromctx" context should be returned first.
+        $fromcontext = context::instance_by_id($fromcontextid);
+        $query .= ' (CASE WHEN ctx.id = :fromcontextid OR ctx.path LIKE :frompath THEN 0 ELSE 1 END),';
+        $params['fromcontextid'] = $fromcontext->id;
+        $params['frompath'] = $fromcontext->path.'/%';
+    }
+    $query .= ' c.sortorder, cm.id';
+    $totalpages = $page + 1;
+
+    // Use core_tag_index_builder to build and filter the list of items.
+    // Request one item more than we need so we know if next page exists.
+    $builder = new core_tag_index_builder('core', 'course_modules', $query, $params, $page * $perpage, $perpage + 1);
+    while ($item = $builder->has_item_that_needs_access_check()) {
+        context_helper::preload_from_record($item);
+        $courseid = $item->courseid;
+        if (!$builder->can_access_course($courseid)) {
+            $builder->set_accessible($item, false);
+            continue;
+        }
+        $modinfo = get_fast_modinfo($builder->get_course($courseid));
+        // Set accessibility of this item and all other items in the same course.
+        $builder->walk(function ($taggeditem) use ($courseid, $modinfo, $builder) {
+            if ($taggeditem->courseid == $courseid) {
+                $cm = $modinfo->get_cm($taggeditem->cmid);
+                $builder->set_accessible($taggeditem, $cm->uservisible);
+            }
+        });
+    }
+
+    $items = $builder->get_items();
+    if (count($items) > $perpage) {
+        $totalpages = $page + 2; // We don't need exact page count, just indicate that the next page exists.
+        array_pop($items);
+    }
+
+    // Build the display contents.
+    if ($items) {
+        $tagfeed = new core_tag\output\tagfeed();
+        foreach ($items as $item) {
+            context_helper::preload_from_record($item);
+            $course = $builder->get_course($item->courseid);
+            $modinfo = get_fast_modinfo($course);
+            $cm = $modinfo->get_cm($item->cmid);
+            $courseurl = course_get_url($item->courseid, $cm->sectionnum);
+            $cmname = $cm->get_formatted_name();
+            if (!$exclusivemode) {
+                $cmname = shorten_text($cmname, 100);
+            }
+            $cmname = html_writer::link($cm->url?:$courseurl, $cmname);
+            $coursename = format_string($course->fullname, true,
+                    array('context' => context_course::instance($item->courseid)));
+            $coursename = html_writer::link($courseurl, $coursename);
+            $icon = html_writer::empty_tag('img', array('src' => $cm->get_icon_url()));
+            $tagfeed->add($icon, $cmname, $coursename);
+        }
+
+        $content = $OUTPUT->render_from_template('core_tag/tagfeed',
+                $tagfeed->export_for_template($OUTPUT));
+
+        return new core_tag\output\tagindex($tag, 'core', 'course_modules', $content,
+                $exclusivemode, $fromcontextid, $contextid, $recursivecontext, $page, $totalpages);
+    }
+}
index 7b435d6..37bd8d0 100644 (file)
@@ -51,7 +51,7 @@ if (!empty($approve) and confirm_sesskey()) {
     $courseid = $course->approve();
 
     if ($courseid !== false) {
-        redirect($CFG->wwwroot.'/course/edit.php?id=' . $courseid);
+        redirect(new moodle_url('/course/edit.php', ['id' => $courseid, 'returnto' => 'pending']));
     } else {
         print_error('courseapprovedfailed');
     }
index 617f4e8..787866a 100644 (file)
@@ -252,14 +252,7 @@ class core_course_renderer extends plugin_renderer_base {
     protected function course_modchooser_module_types($modules) {
         $return = '';
         foreach ($modules as $module) {
-            if (!isset($module->types)) {
-                $return .= $this->course_modchooser_module($module);
-            } else {
-                $return .= $this->course_modchooser_module($module, array('nonoption'));
-                foreach ($module->types as $type) {
-                    $return .= $this->course_modchooser_module($type, array('option', 'subtype'));
-                }
-            }
+            $return .= $this->course_modchooser_module($module);
         }
         return $return;
     }
@@ -443,39 +436,15 @@ class core_course_renderer extends plugin_renderer_base {
         $activities = array(MOD_CLASS_ACTIVITY => array(), MOD_CLASS_RESOURCE => array());
 
         foreach ($modules as $module) {
-            if (isset($module->types)) {
-                // This module has a subtype
-                // NOTE: this is legacy stuff, module subtypes are very strongly discouraged!!
-                $subtypes = array();
-                foreach ($module->types as $subtype) {
-                    $link = $subtype->link->out(true, $urlparams);
-                    $subtypes[$link] = $subtype->title;
-                }
-
-                // Sort module subtypes into the list
-                $activityclass = MOD_CLASS_ACTIVITY;
-                if ($module->archetype == MOD_CLASS_RESOURCE) {
-                    $activityclass = MOD_CLASS_RESOURCE;
-                }
-                if (!empty($module->title)) {
-                    // This grouping has a name
-                    $activities[$activityclass][] = array($module->title => $subtypes);
-                } else {
-                    // This grouping does not have a name
-                    $activities[$activityclass] = array_merge($activities[$activityclass], $subtypes);
-                }
-            } else {
-                // This module has no subtypes
-                $activityclass = MOD_CLASS_ACTIVITY;
-                if ($module->archetype == MOD_ARCHETYPE_RESOURCE) {
-                    $activityclass = MOD_CLASS_RESOURCE;
-                } else if ($module->archetype === MOD_ARCHETYPE_SYSTEM) {
-                    // System modules cannot be added by user, do not add to dropdown
-                    continue;
-                }
-                $link = $module->link->out(true, $urlparams);
-                $activities[$activityclass][$link] = $module->title;
+            $activityclass = MOD_CLASS_ACTIVITY;
+            if ($module->archetype == MOD_ARCHETYPE_RESOURCE) {
+                $activityclass = MOD_CLASS_RESOURCE;
+            } else if ($module->archetype === MOD_ARCHETYPE_SYSTEM) {
+                // System modules cannot be added by user, do not add to dropdown.
+                continue;
             }
+            $link = $module->link->out(true, $urlparams);
+            $activities[$activityclass][$link] = $module->title;
         }
 
         $straddactivity = get_string('addactivity');
index aa50329..26400e3 100644 (file)
@@ -2809,4 +2809,117 @@ class core_course_courselib_testcase extends advanced_testcase {
         $this->assertEquals('New forum name', $res['value']);
         $this->assertEquals('New forum name', $DB->get_field('forum', 'name', array('id' => $forum->id)));
     }
+
+    /**
+     * Testing function course_get_tagged_course_modules - search tagged course modules
+     */
+    public function test_course_get_tagged_course_modules() {
+        global $DB;
+        $this->resetAfterTest();
+        $course3 = $this->getDataGenerator()->create_course();
+        $course2 = $this->getDataGenerator()->create_course();
+        $course1 = $this->getDataGenerator()->create_course();
+        $cm11 = $this->getDataGenerator()->create_module('assign', array('course' => $course1->id,
+            'tags' => 'Cat, Dog'));
+        $cm12 = $this->getDataGenerator()->create_module('page', array('course' => $course1->id,
+            'tags' => 'Cat, Mouse', 'visible' => 0));
+        $cm13 = $this->getDataGenerator()->create_module('page', array('course' => $course1->id,
+            'tags' => 'Cat, Mouse, Dog'));
+        $cm21 = $this->getDataGenerator()->create_module('forum', array('course' => $course2->id,
+            'tags' => 'Cat, Mouse'));
+        $cm31 = $this->getDataGenerator()->create_module('forum', array('course' => $course3->id,
+            'tags' => 'Cat, Mouse'));
+
+        // Admin is able to view everything.
+        $this->setAdminUser();
+        $res = course_get_tagged_course_modules(core_tag_tag::get_by_name(0, 'Cat'),
+                /*$exclusivemode = */false, /*$fromctx = */0, /*$ctx = */0, /*$rec = */1, /*$page = */0);
+        $this->assertRegExp('/'.$cm11->name.'/', $res->content);
+        $this->assertRegExp('/'.$cm12->name.'/', $res->content);
+        $this->assertRegExp('/'.$cm13->name.'/', $res->content);
+        $this->assertRegExp('/'.$cm21->name.'/', $res->content);
+        $this->assertRegExp('/'.$cm31->name.'/', $res->content);
+        // Results from course1 are returned before results from course2.
+        $this->assertTrue(strpos($res->content, $cm11->name) < strpos($res->content, $cm21->name));
+
+        // Ordinary user is not able to see anything.
+        $user = $this->getDataGenerator()->create_user();
+        $this->setUser($user);
+
+        $res = course_get_tagged_course_modules(core_tag_tag::get_by_name(0, 'Cat'),
+                /*$exclusivemode = */false, /*$fromctx = */0, /*$ctx = */0, /*$rec = */1, /*$page = */0);
+        $this->assertNull($res);
+
+        // Enrol user as student in course1 and course2.
+        $roleids = $DB->get_records_menu('role', null, '', 'shortname, id');
+        $this->getDataGenerator()->enrol_user($user->id, $course1->id, $roleids['student']);
+        $this->getDataGenerator()->enrol_user($user->id, $course2->id, $roleids['student']);
+        core_tag_index_builder::reset_caches();
+
+        // Searching in the course context returns visible modules in this course.
+        $context = context_course::instance($course1->id);
+        $res = course_get_tagged_course_modules(core_tag_tag::get_by_name(0, 'Cat'),
+                /*$exclusivemode = */false, /*$fromctx = */0, /*$ctx = */$context->id, /*$rec = */1, /*$page = */0);
+        $this->assertRegExp('/'.$cm11->name.'/', $res->content);
+        $this->assertNotRegExp('/'.$cm12->name.'/', $res->content);
+        $this->assertRegExp('/'.$cm13->name.'/', $res->content);
+        $this->assertNotRegExp('/'.$cm21->name.'/', $res->content);
+        $this->assertNotRegExp('/'.$cm31->name.'/', $res->content);
+
+        // Searching FROM the course context returns visible modules in all courses.
+        $context = context_course::instance($course2->id);
+        $res = course_get_tagged_course_modules(core_tag_tag::get_by_name(0, 'Cat'),
+                /*$exclusivemode = */false, /*$fromctx = */$context->id, /*$ctx = */0, /*$rec = */1, /*$page = */0);
+        $this->assertRegExp('/'.$cm11->name.'/', $res->content);
+        $this->assertNotRegExp('/'.$cm12->name.'/', $res->content);
+        $this->assertRegExp('/'.$cm13->name.'/', $res->content);
+        $this->assertRegExp('/'.$cm21->name.'/', $res->content);
+        $this->assertNotRegExp('/'.$cm31->name.'/', $res->content); // No access to course3.
+        // Results from course2 are returned before results from course1.
+        $this->assertTrue(strpos($res->content, $cm21->name) < strpos($res->content, $cm11->name));
+
+        // Enrol user in course1 as a teacher - now he should be able to see hidden module.
+        $this->getDataGenerator()->enrol_user($user->id, $course1->id, $roleids['editingteacher']);
+        get_fast_modinfo(0,0,true);
+
+        $context = context_course::instance($course1->id);
+        $res = course_get_tagged_course_modules(core_tag_tag::get_by_name(0, 'Cat'),
+                /*$exclusivemode = */false, /*$fromctx = */$context->id, /*$ctx = */0, /*$rec = */1, /*$page = */0);
+        $this->assertRegExp('/'.$cm12->name.'/', $res->content);
+
+        // Create more modules and try pagination.
+        $cm14 = $this->getDataGenerator()->create_module('assign', array('course' => $course1->id,
+            'tags' => 'Cat, Dog'));
+        $cm15 = $this->getDataGenerator()->create_module('page', array('course' => $course1->id,
+            'tags' => 'Cat, Mouse', 'visible' => 0));
+        $cm16 = $this->getDataGenerator()->create_module('page', array('course' => $course1->id,
+            'tags' => 'Cat, Mouse, Dog'));
+
+        $context = context_course::instance($course1->id);
+        $res = course_get_tagged_course_modules(core_tag_tag::get_by_name(0, 'Cat'),
+                /*$exclusivemode = */false, /*$fromctx = */0, /*$ctx = */$context->id, /*$rec = */1, /*$page = */0);
+        $this->assertRegExp('/'.$cm11->name.'/', $res->content);
+        $this->assertRegExp('/'.$cm12->name.'/', $res->content);
+        $this->assertRegExp('/'.$cm13->name.'/', $res->content);
+        $this->assertNotRegExp('/'.$cm21->name.'/', $res->content);
+        $this->assertRegExp('/'.$cm14->name.'/', $res->content);
+        $this->assertRegExp('/'.$cm15->name.'/', $res->content);
+        $this->assertNotRegExp('/'.$cm16->name.'/', $res->content);
+        $this->assertNotRegExp('/'.$cm31->name.'/', $res->content); // No access to course3.
+        $this->assertEmpty($res->prevpageurl);
+        $this->assertNotEmpty($res->nextpageurl);
+
+        $res = course_get_tagged_course_modules(core_tag_tag::get_by_name(0, 'Cat'),
+                /*$exclusivemode = */false, /*$fromctx = */0, /*$ctx = */$context->id, /*$rec = */1, /*$page = */1);
+        $this->assertNotRegExp('/'.$cm11->name.'/', $res->content);
+        $this->assertNotRegExp('/'.$cm12->name.'/', $res->content);
+        $this->assertNotRegExp('/'.$cm13->name.'/', $res->content);
+        $this->assertNotRegExp('/'.$cm21->name.'/', $res->content);
+        $this->assertNotRegExp('/'.$cm14->name.'/', $res->content);
+        $this->assertNotRegExp('/'.$cm15->name.'/', $res->content);
+        $this->assertRegExp('/'.$cm16->name.'/', $res->content);
+        $this->assertNotRegExp('/'.$cm31->name.'/', $res->content); // No access to course3.
+        $this->assertNotEmpty($res->prevpageurl);
+        $this->assertEmpty($res->nextpageurl);
+    }
 }
index 10a84ba..81af78e 100644 (file)
@@ -652,6 +652,14 @@ class core_course_externallib_testcase extends externallib_advanced_testcase {
 
         // Now as a normal user.
         $user = self::getDataGenerator()->create_user();
+
+        // Add a 3rd, hidden, course we shouldn't see, even when enrolled as student.
+        $coursedata3['fullname'] = 'HIDDEN COURSE';
+        $coursedata3['visible'] = 0;
+        $course3  = self::getDataGenerator()->create_course($coursedata3);
+        $this->getDataGenerator()->enrol_user($user->id, $course3->id, 'student');
+
+        $this->getDataGenerator()->enrol_user($user->id, $course2->id, 'student');
         $this->setUser($user);
 
         $results = core_course_external::search_courses('search', 'FIRST');
@@ -660,6 +668,19 @@ class core_course_externallib_testcase extends externallib_advanced_testcase {
         $this->assertEquals(1, $results['total']);
         $this->assertEquals($coursedata1['fullname'], $results['courses'][0]['fullname']);
 
+        // Check that we can see both without the limit to enrolled setting.
+        $results = core_course_external::search_courses('search', 'COURSE', 0, 0, array(), 0);
+        $results = external_api::clean_returnvalue(core_course_external::search_courses_returns(), $results);
+        $this->assertCount(2, $results['courses']);
+        $this->assertEquals(2, $results['total']);
+
+        // Check that we only see our enrolled course when limiting.
+        $results = core_course_external::search_courses('search', 'COURSE', 0, 0, array(), 1);
+        $results = external_api::clean_returnvalue(core_course_external::search_courses_returns(), $results);
+        $this->assertCount(1, $results['courses']);
+        $this->assertEquals(1, $results['total']);
+        $this->assertEquals($coursedata2['fullname'], $results['courses'][0]['fullname']);
+
         // Search by block (use news_items default block). Should fail (only admins allowed).
         $this->setExpectedException('required_capability_exception');
         $results = core_course_external::search_courses('blocklist', $blockid);
index caa4e19..928094e 100644 (file)
@@ -647,21 +647,25 @@ class gradingform_guide_renderer extends plugin_renderer_base {
                 $checked_s2 = $checked;
             }
 
-            $radio = html_writer::tag('input', get_string('showmarkerdesc', 'gradingform_guide'), array('type' => 'radio',
+            $radio1 = html_writer::tag('input', get_string('showmarkerdesc', 'gradingform_guide'), array('type' => 'radio',
                 'name' => 'showmarkerdesc',
                 'value' => "true")+$checked1);
-            $radio .= html_writer::tag('input', get_string('hidemarkerdesc', 'gradingform_guide'), array('type' => 'radio',
+            $radio1 = html_writer::tag('label', $radio1);
+            $radio2 = html_writer::tag('input', get_string('hidemarkerdesc', 'gradingform_guide'), array('type' => 'radio',
                 'name' => 'showmarkerdesc',
                 'value' => "false")+$checked2);
-            $output .= html_writer::tag('div', $radio, array('class' => 'showmarkerdesc'));
+            $radio2 = html_writer::tag('label', $radio2);
+            $output .= html_writer::tag('div', $radio1 . $radio2, array('class' => 'showmarkerdesc'));
 
-            $radio = html_writer::tag('input', get_string('showstudentdesc', 'gradingform_guide'), array('type' => 'radio',
+            $radio1 = html_writer::tag('input', get_string('showstudentdesc', 'gradingform_guide'), array('type' => 'radio',
                 'name' => 'showstudentdesc',
                 'value' => "true")+$checked_s1);
-            $radio .= html_writer::tag('input', get_string('hidestudentdesc', 'gradingform_guide'), array('type' => 'radio',
+            $radio1 = html_writer::tag('label', $radio1);
+            $radio2 = html_writer::tag('input', get_string('hidestudentdesc', 'gradingform_guide'), array('type' => 'radio',
                 'name' => 'showstudentdesc',
                 'value' => "false")+$checked_s2);
-            $output .= html_writer::tag('div', $radio, array('class' => 'showstudentdesc'));
+            $radio2 = html_writer::tag('label', $radio2);
+            $output .= html_writer::tag('div', $radio1 . $radio2, array('class' => 'showstudentdesc'));
         }
         return $output;
     }
@@ -775,4 +779,4 @@ class gradingform_guide_renderer extends plugin_renderer_base {
 
         return $html;
     }
-}
\ No newline at end of file
+}
index c7ee03d..abe994a 100644 (file)
@@ -84,9 +84,11 @@ Feature: Marking guides can be created and edited
     And I wait "1" seconds
     Then the field "Guide criterion B criterion remark" matches value "Comment 4"
     When I press "Save changes"
-    Then I should see "The grade changes were saved"
+    And I press "Ok"
+    And I follow "Edit settings"
+    And I follow "Test assignment 1 name"
+    And I follow "View all submissions"
     # Checking that the user grade is correct.
-    When I press "Continue"
     Then I should see "80" in the "Student 1" "table_row"
     And I log out
     # Viewing it as a student.
index c2ee6f4..232e995 100644 (file)
@@ -4,7 +4,8 @@ Feature: Rubrics can be created and edited
   As a teacher
   I need to edit previously used rubrics
 
-  Background:
+  @javascript
+  Scenario: I can use rubrics to grade and edit them later updating students grades
     Given the following "users" exist:
       | username | firstname | lastname | email |
       | teacher1 | Teacher | 1 | teacher1@example.com |
@@ -152,8 +153,3 @@ Feature: Rubrics can be created and edited
     And I should not see "Criterion 2" in the ".submissionstatustable" "css_element"
     And I should not see "Criterion 3" in the ".submissionstatustable" "css_element"
     And I should not see "Rubric test description" in the ".feedback" "css_element"
-
-  @javascript
-  Scenario: I can use rubrics to grade and edit them later updating students grades with Javascript enabled
-
-  Scenario: I can use rubrics to grade and edit them later updating students grades with Javascript disabled
index 5f32cac..0ee77e5 100644 (file)
@@ -82,21 +82,19 @@ class behat_grading extends behat_base {
     public function i_go_to_activity_advanced_grading_page($userfullname, $activityname) {
 
         // Step to access the user grade page from the grading page.
-        $usergradetext = get_string('gradeuser', 'assign', $userfullname);
-
-        // Shortcut in case we already are in the grading page.
-        $usergradetextliteral = behat_context_helper::escape($usergradetext);
-        if ($this->getSession()->getPage()->find('named_partial', array('link', $usergradetextliteral))) {
-            $this->execute('behat_general::click_link', $this->escape($usergradetext));
-
-            return true;
-        }
+        $gradetext = get_string('grade');
 
         $this->execute('behat_general::click_link', $this->escape($activityname));
 
         $this->execute('behat_general::click_link', $this->escape(get_string('viewgrading', 'assign')));
 
-        $this->execute('behat_general::click_link', $this->escape($usergradetext));
+        $this->execute('behat_general::i_click_on_in_the',
+                       array(
+                           $this->escape($gradetext),
+                           'link',
+                           $this->escape($userfullname),
+                           'table_row'
+                       ));
     }
 
     /**
@@ -156,7 +154,10 @@ class behat_grading extends behat_base {
     public function i_save_the_advanced_grading_form() {
 
         $this->execute('behat_forms::press_button', get_string('savechanges'));
-        $this->execute('behat_forms::press_button', get_string('continue'));
+        $this->execute('behat_forms::press_button', 'Ok');
+        $this->execute('behat_general::i_click_on', array($this->escape(get_string('editsettings')), 'link'));
+        $this->execute('behat_forms::press_button', get_string('cancel'));
+        $this->execute('behat_general::i_click_on', array($this->escape(get_string('viewgrading', 'mod_assign')), 'link'));
     }
 
     /**
index 218a832..ce9905d 100644 (file)
@@ -1,4 +1,4 @@
-@core @core_grades @gradereport_singleview
+@core @core_grades @gradereport_singleview @javascript
 Feature: We can bulk insert grades for students in a course
   As a teacher
   In order to quickly grade items
@@ -33,12 +33,13 @@ Feature: We can bulk insert grades for students in a course
     Given I log in as "teacher1"
     And I follow "Course 1"
     And I follow "Test assignment one"
-    And I follow "View/grade all submissions"
-    And I follow "Grade Student 1"
+    And I follow "View all submissions"
+    And I click on "Grade" "link" in the "Student 1" "table_row"
     And I set the following fields to these values:
       | Grade out of 100 | 50 |
     And I press "Save changes"
-    And I press "Continue"
+    And I press "Ok"
+    And I follow "Edit settings"
     And I follow "View gradebook"
     And I follow "Single view for Test assignment one"
     Then the field "Grade for james (Student) 1" matches value "50.00"
@@ -73,13 +74,16 @@ Feature: We can bulk insert grades for students in a course
     Given I log in as "teacher1"
     And I follow "Course 1"
     And I follow "Test assignment two"
-    And I follow "View/grade all submissions"
-    And I follow "Grade Student 1"
+    And I follow "View all submissions"
+    And I click on "Grade" "link" in the "Student 1" "table_row"
     And I set the following fields to these values:
       | Grade out of 100 | 50 |
     And I press "Save changes"
-    And I press "Continue"
+    And I press "Ok"
+    And I follow "Edit settings"
     And I follow "View gradebook"
+    And I click on "input[title='Dock Navigation block']" "css_element"
+    And I click on "input[title='Dock Administration block']" "css_element"
     And I follow "Single view for Test assignment two"
     And I select "Student 1" from the "Select user..." singleselect
     Then the field "Grade for Test assignment two" matches value "50.00"
index ed42b97..f6485ed 100644 (file)
@@ -1,4 +1,4 @@
-@core @core_grades
+@core @core_grades @javascript
 Feature: View gradebook when scales are used
   In order to use scales to grade activities
   As an teacher
@@ -49,18 +49,27 @@ Feature: View gradebook when scales are used
     And I set the field "grade[modgrade_type]" to "Scale"
     And I set the field "grade[modgrade_scale]" to "Letterscale"
     And I press "Save and display"
-    And I follow "View/grade all submissions"
-    And I click on "Grade Student 1" "link" in the "Student 1" "table_row"
+    And I follow "View all submissions"
+    And I click on "Grade" "link" in the "Student 1" "table_row"
     And I set the field "Grade" to "A"
-    And I press "Save and show next"
+    And I press "Save changes"
+    And I press "Ok"
+    And I click on "[data-action=next-user]" "css_element"
     And I set the field "Grade" to "B"
-    And I press "Save and show next"
+    And I press "Save changes"
+    And I press "Ok"
+    And I click on "[data-action=next-user]" "css_element"
     And I set the field "Grade" to "C"
-    And I press "Save and show next"
+    And I press "Save changes"
+    And I press "Ok"
+    And I click on "[data-action=next-user]" "css_element"
     And I set the field "Grade" to "D"
-    And I press "Save and show next"
+    And I press "Save changes"
+    And I press "Ok"
+    And I click on "[data-action=next-user]" "css_element"
     And I set the field "Grade" to "F"
     And I press "Save changes"
+    And I press "Ok"
     And I follow "Course 1"
     And I navigate to "Grades" node in "Course administration"
     And I navigate to "Course grade settings" node in "Grade administration > Setup"
index ed3a96a..f62db0a 100644 (file)
@@ -1,4 +1,4 @@
-@core @core_grades
+@core @core_grades @javascript
 Feature: View gradebook when single item scales are used
   In order to use single item scales to grade activities
   As an teacher
@@ -43,10 +43,11 @@ Feature: View gradebook when single item scales are used
     And I set the field "grade[modgrade_type]" to "Scale"
     And I set the field "grade[modgrade_scale]" to "Singleitem"
     And I press "Save and display"
-    And I follow "View/grade all submissions"
-    And I click on "Grade Student 1" "link" in the "Student 1" "table_row"
+    And I follow "View all submissions"
+    And I click on "Grade" "link" in the "Student 1" "table_row"
     And I set the field "Grade" to "A"
     And I press "Save changes"
+    And I press "Ok"
     And I follow "Course 1"
     And I navigate to "Grades" node in "Course administration"
     And I navigate to "Course grade settings" node in "Grade administration > Setup"
index df0275c..8e4178b 100644 (file)
@@ -35,7 +35,7 @@ $string['availablelangs'] = 'Llista d\'idiomes disponibles';
 $string['chooselanguagehead'] = 'Trieu un idioma';
 $string['chooselanguagesub'] = 'Trieu un idioma per a la instal·lació. S\'utilitzarà també com a idioma per defecte del lloc, tot i que després podeu canviar-lo.';
 $string['clialreadyconfigured'] = 'El fitxer config.php ja existeix, feu servir dmin/cli/install_database.php si voleu instal·lar aquest lloc web.';
-$string['clialreadyinstalled'] = 'El fitxer config.php ja existeix, feu servir admin/cli/upgrade.php si voleu actualitzar aquest lloc web.';
+$string['clialreadyinstalled'] = 'El fitxer de configuració config.php ja existeix. Feu servir admin/cli/upgrade.php si voleu actualitzar Moodle per a aquest lloc web.';
 $string['cliinstallheader'] = 'Programa d\'instal·lació de línia d\'ordres de Moodle {$a}';
 $string['databasehost'] = 'Servidor de base de dades:';
 $string['databasename'] = 'Nom de la base de dades:';
index dc2ca33..ac33cab 100644 (file)
@@ -70,7 +70,7 @@ $string['pathsrodataroot'] = 'Do datového adresáře nelze zapisovat.';
 $string['pathsroparentdataroot'] = 'Do nadřazeného adresáře ({$a->parent}) nelze zapisovat. Datový adresář ({$a->dataroot}) nemůže být tímto průvodcem instalací vytvořen.';
 $string['pathssubadmindir'] = 'Na některých serverech je URL adresa /admin vyhrazena pro speciální účely (např. pro ovládací panel). Na takových serverech může dojít ke kolizi se standardním umístěním stránek pro správu Moodle. Máte-li tento problém, přejmenujte adresář <eM>admin</em> ve vaší instalaci Moodle a sem zadejte jeho nový název - například <em>moodleadmin</em>. Všechny generované odkazy na stránky správy Moodle budou používat tento nový název.';
 $string['pathssubdataroot'] = 'Moodle potřebuje prostor, kam si bude ukládat nahrané soubory a další údaje. K tomuto adresáři musí mít proces webového serveru právo ke čtení i k zápisu (webový server bývá většinou spouštěn pod uživatelem "www-data" nebo "apache"). Tento adresář ale zároveň nesmí být dostupný přímo přes webové rozhraní. Instalační skript se pokusí tento adresář vytvořit, pokud nebude existovat.';
-$string['pathssubdirroot'] = 'Absolutní cesta k adresáři s instalací Moodle';
+$string['pathssubdirroot'] = '<p>Absolutní cesta k adresáři s instalací Moodle.</p>';
 $string['pathssubwwwroot'] = 'Zadejte úplnou webovou adresu, na níž bude Moodle dostupný. Moodle potřebuje jedinečnou adresu, není možné jej provozovat na několika URL současně. Používáte-li několik veřejných domén, musíte si sami nastavit permanentní přesměrování na jednu z nich a tu pak použít. Pokud je váš server dostupný z vnější a z vnitřní sítě pod různými IP adresami, použijte jeho veřejnou adresu a nastavte si váš DNS server tak, že ji mohou používat i uživatelé z vnitřní sítě.';
 $string['pathsunsecuredataroot'] = 'Umístění datového adresáře není bezpečné';
 $string['pathswrongadmindir'] = 'Adresář pro správu serveru (admin) neexistuje';
index fb392c1..9455d53 100644 (file)
@@ -790,6 +790,8 @@ $string['pathtopgdumpinvalid'] = 'Invalid path to pg_dump - either wrong path or
 $string['pathtopsql'] = 'Path to psql';
 $string['pathtopsqldesc'] = 'This is only necessary to enter if you have more than one psql on your system (for example if you have more than one version of postgresql installed)';
 $string['pathtopsqlinvalid'] = 'Invalid path to psql - either wrong path or not executable';
+$string['pathtounoconv'] = 'Path to unoconv document converter';
+$string['pathtounoconv_help'] = 'Path to unoconv document converter. This is an executable that is capable of converting between document formats supported by LibreOffice. This is optional, but if specified, Moodle will use it to automatically convert between document formats. This is used to support a wider range of input files for the assignment annotate PDF feature.';
 $string['pcreunicodewarning'] = 'It is strongly recommended to use PCRE PHP extension that is compatible with Unicode characters.';
 $string['perfdebug'] = 'Performance info';
 $string['performance'] = 'Performance';
index 8ebb54e..5936520 100644 (file)
@@ -57,6 +57,7 @@ $string['cachedef_navigation_expandcourse'] = 'Navigation expandable courses';
 $string['cachedef_observers'] = 'Event observers';
 $string['cachedef_plugin_functions'] = 'Plugins available callbacks';
 $string['cachedef_plugin_manager'] = 'Plugin info manager';
+$string['cachedef_tagindexbuilder'] = 'Search results for tagged items';
 $string['cachedef_questiondata'] = 'Question definitions';
 $string['cachedef_repositories'] = 'Repositories instances data';
 $string['cachedef_grade_categories'] = 'Grade category queries';
index 237e920..a540774 100644 (file)
@@ -39,6 +39,7 @@ $string['activityreport'] = 'Activity report';
 $string['activityreports'] = 'Activity reports';
 $string['activityselect'] = 'Select this activity to be moved elsewhere';
 $string['activitysince'] = 'Activity since {$a}';
+$string['activitytypetitle'] = '{$a->activity} - {$a->type}';
 $string['activityweighted'] = 'Activity per user';
 $string['add'] = 'Add';
 $string['addactivity'] = 'Add an activity...';
index b1e9455..be993f3 100644 (file)
@@ -25,6 +25,7 @@
 $string['advancedsearch'] = 'Advanced search';
 $string['all'] = 'All';
 $string['allareas'] = 'All areas';
+$string['allcourses'] = 'All courses';
 $string['author'] = 'Author';
 $string['authorname'] = 'Author name';
 $string['back'] = 'Back';
index ccc6836..15fc377 100644 (file)
@@ -70,8 +70,11 @@ $string['flagasinappropriate'] = 'Flag as inappropriate';
 $string['helprelatedtags'] = 'Comma separated related tags';
 $string['changename'] = 'Change tag name';
 $string['changetype'] = 'Change tag type';
+$string['combined'] = 'Tags are combined';
+$string['combineselected'] = 'Combine selected';
 $string['id'] = 'id';
 $string['inalltagcoll'] = 'Everywhere';
+$string['inputstandardtags'] = 'Enter comma-separated list of new tags';
 $string['itemstaggedwith'] = '{$a->tagarea} tagged with "{$a->tag}"';
 $string['lesstags'] = 'less...';
 $string['managestandardtags'] = 'Manage standard tags';
@@ -80,6 +83,7 @@ $string['managetagcolls'] = 'Manage tag collections';
 $string['moretags'] = 'more...';
 $string['name'] = 'Tag name';
 $string['namesalreadybeeingused'] = 'Tag names already being used';
+$string['nameuseddocombine'] = 'This tag name is already used, do you want to combine these tags?';
 $string['newcollnamefor'] = 'New name for tag collection {$a}';
 $string['newnamefor'] = 'New name for tag {$a}';
 $string['nextpage'] = 'More';
@@ -93,6 +97,7 @@ $string['relatedblogs'] = 'Most recent blog entries';
 $string['relatedtags'] = 'Related tags';
 $string['removetagfrommyinterests'] = 'Remove "{$a}" from my interests';
 $string['reset'] = 'Tag flag reset';
+$string['resetfilter'] = 'Reset filter';
 $string['resetflag'] = 'Reset flag';
 $string['responsiblewillbenotified'] = 'The person responsible will be notified';
 $string['rssdesc'] = 'This RSS feed was automatically generated by Moodle and contains user generated tags for courses.';
@@ -107,6 +112,8 @@ $string['searchtags'] = 'Search tags';
 $string['seeallblogs'] = 'See all blog entries tagged with "{$a}"';
 $string['select'] = 'Select';
 $string['selectcoll'] = 'Select tag collection';
+$string['selectmaintag'] = 'Select the tag that will be used after combining';
+$string['selectmultipletags'] = 'Please select more than one tag';
 $string['selecttag'] = 'Select tag {$a}';
 $string['settypedefault'] = 'Remove from standard tags';
 $string['settypestandard'] = 'Make standard';
index e72f165..d3d23ab 100644 (file)
@@ -41,66 +41,15 @@ if ($requests === null) {
 }
 $responses = array();
 
-
 foreach ($requests as $request) {
     $response = array();
     $methodname = clean_param($request['methodname'], PARAM_ALPHANUMEXT);
     $index = clean_param($request['index'], PARAM_INT);
     $args = $request['args'];
 
-    try {
-        $externalfunctioninfo = external_function_info($methodname);
-
-        if (!$externalfunctioninfo->allowed_from_ajax) {
-            error_log('This external function is not available to ajax. Failed to call "' . $methodname . '"');
-            throw new moodle_exception('servicenotavailable', 'webservice');
-        }
-
-        // Do not allow access to write or delete webservices as a public user.
-        if ($externalfunctioninfo->loginrequired) {
-            if (defined('NO_MOODLE_COOKIES') && NO_MOODLE_COOKIES) {
-                error_log('Set "loginrequired" to false in db/service.php when calling entry point service-nologin.php. ' .
-                          'Failed to call "' . $methodname . '"');
-                throw new moodle_exception('servicenotavailable', 'webservice');
-            }
-            if (!isloggedin()) {
-                error_log('This external function is not available to public users. Failed to call "' . $methodname . '"');
-                throw new moodle_exception('servicenotavailable', 'webservice');
-            } else {
-                require_sesskey();
-            }
-        }
-
-        // Validate params, this also sorts the params properly, we need the correct order in the next part.
-        $callable = array($externalfunctioninfo->classname, 'validate_parameters');
-        $params = call_user_func($callable,
-                                 $externalfunctioninfo->parameters_desc,
-                                 $args);
-
-        // Execute - gulp!
-        $callable = array($externalfunctioninfo->classname, $externalfunctioninfo->methodname);
-        $result = call_user_func_array($callable,
-                                       array_values($params));
-
-        // Validate the return parameters.
-        if ($externalfunctioninfo->returns_desc !== null) {
-            $callable = array($externalfunctioninfo->classname, 'clean_returnvalue');
-            $result = call_user_func($callable, $externalfunctioninfo->returns_desc, $result);
-        }
-
-        $response['error'] = false;
-        $response['data'] = $result;
-        $responses[$index] = $response;
-    } catch (Exception $e) {
-        $jsonexception = get_exception_info($e);
-        unset($jsonexception->a);
-        if (!debugging('', DEBUG_DEVELOPER)) {
-            unset($jsonexception->debuginfo);
-            unset($jsonexception->backtrace);
-        }
-        $response['error'] = true;
-        $response['exception'] = $jsonexception;
-        $responses[$index] = $response;
+    $response = external_api::call_external_function($methodname, $args, true);
+    $responses[$index] = $response;
+    if ($response['error']) {
         // Do not process the remaining requests.
         break;
     }
index 76fd2b9..fb06114 100644 (file)
Binary files a/lib/amd/build/form-autocomplete.min.js and b/lib/amd/build/form-autocomplete.min.js differ
index 6eaaee6..0417989 100644 (file)
Binary files a/lib/amd/build/form-course-selector.min.js and b/lib/amd/build/form-course-selector.min.js differ
index e5cfa40..fdb2a95 100644 (file)
Binary files a/lib/amd/build/tag.min.js and b/lib/amd/build/tag.min.js differ
diff --git a/lib/amd/build/tooltip.min.js b/lib/amd/build/tooltip.min.js
new file mode 100644 (file)
index 0000000..b29a52a
Binary files /dev/null and b/lib/amd/build/tooltip.min.js differ
index ba27f86..3a7fc53 100644 (file)
@@ -686,8 +686,9 @@ define(['jquery', 'core/log', 'core/str', 'core/templates', 'core/notification']
          *                      These are modeled on Select2 see: https://select2.github.io/options.html#ajax
          * @param {String} placeholder - The text to display before a selection is made.
          * @param {Boolean} caseSensitive - If search has to be made case sensitive.
+         * @param {String} noSelectionString - Text to display when there is no selection
          */
-        enhance: function(selector, tags, ajax, placeholder, caseSensitive, showSuggestions) {
+        enhance: function(selector, tags, ajax, placeholder, caseSensitive, showSuggestions, noSelectionString) {
             // Set some default values.
             var options = {
                 selector: selector,
@@ -695,7 +696,8 @@ define(['jquery', 'core/log', 'core/str', 'core/templates', 'core/notification']
                 ajax: false,
                 placeholder: placeholder,
                 caseSensitive: false,
-                showSuggestions: true
+                showSuggestions: true,
+                noSelectionString: noSelectionString
             };
             if (typeof tags !== "undefined") {
                 options.tags = tags;
@@ -709,6 +711,9 @@ define(['jquery', 'core/log', 'core/str', 'core/templates', 'core/notification']
             if (typeof showSuggestions !== "undefined") {
                 options.showSuggestions = showSuggestions;
             }
+            if (typeof noSelectionString === "undefined") {
+                options.noSelectionString = str.get_string('noselection', 'form');
+            }
 
             // Look for the select element.
             var originalSelect = $(selector);
@@ -778,8 +783,7 @@ define(['jquery', 'core/log', 'core/str', 'core/templates', 'core/notification']
                             throttleTimeout = window.setTimeout(handler.bind(this, e), 300);
                         };
                         // Trigger an ajax update after the text field value changes.
-                        inputElement.on("input keypress", throttledHandler);
-
+                        inputElement.on("input", throttledHandler);
                         var arrowElement = $(document.getElementById(state.downArrowId));
                         arrowElement.on("click", handler);
                     });
index 2bf7aed..78c71af 100644 (file)
@@ -48,6 +48,9 @@ define(['core/ajax', 'jquery'], function(ajax, $) {
             } else {
                 requiredcapabilities = [];
             }
+
+            var limittoenrolled = $(selector).data('limittoenrolled');
+
             // Build the query.
             var promise = null;
 
@@ -60,7 +63,8 @@ define(['core/ajax', 'jquery'], function(ajax, $) {
                 criteriavalue: query,
                 page: 0,
                 perpage: 100,
-                requiredcapabilities: requiredcapabilities
+                requiredcapabilities: requiredcapabilities,
+                limittoenrolled: limittoenrolled
             };
             // Go go go!
             promise = ajax.call([{
index 65af5f5..bb81962 100644 (file)
@@ -100,6 +100,7 @@ define(['jquery', 'core/ajax', 'core/templates', 'core/notification', 'core/str'
                 if (!cnt) {
                     return false;
                 }
+                var tempElement = $("<input type='hidden'/>").attr('name', this.name);
                 e.preventDefault();
                 str.get_strings([
                         {key : 'delete'},
@@ -108,11 +109,128 @@ define(['jquery', 'core/ajax', 'core/templates', 'core/notification', 'core/str'
                         {key : 'no'},
                     ]).done(function(s) {
                         notification.confirm(s[0], s[1], s[2], s[3], function() {
+                            tempElement.appendTo(form);
                             form.submit();
                         });
                     }
                 );
             });
+
+            // Confirmation for bulk tag combine button.
+            $("#tag-management-combine").click(function(e){
+                e.preventDefault();
+                var form = $(this).closest('form').get(0),
+                    tags = $(form).find("input[type=checkbox]:checked");
+                if (tags.length <= 1) {
+                    str.get_strings([
+                        {key : 'combineselected', component : 'tag'},
+                        {key : 'selectmultipletags', component : 'tag'},
+                        {key : 'ok'},
+                    ]).done(function(s) {
+                            notification.alert(s[0], s[1], s[2]);
+                        }
+                    );
+                    return false;
+                }
+                var tempElement = $("<input type='hidden'/>").attr('name', this.name);
+                str.get_strings([
+                    {key : 'combineselected', component : 'tag'},
+                    {key : 'selectmaintag', component : 'tag'},
+                    {key : 'continue'},
+                    {key : 'cancel'},
+                ]).done(function(s) {
+                    var el = $('<div><form id="combinetags_form" class="form-inline">'+
+                        '<p class="description"></p><p class="options"></p>' +
+                        '<p class="mdl-align"><input type="submit" id="combinetags_submit"/>'+
+                        '<input type="button" id="combinetags_cancel"/></p>' +
+                        '</form></div>');
+                    el.find('.description').html(s[1]);
+                    el.find('#combinetags_submit').attr('value', s[2]);
+                    el.find('#combinetags_cancel').attr('value', s[3]);
+                    var fldset = el.find('.options');
+                    tags.each(function() {
+                        var tagid = $(this).val(),
+                            tagname = $('.inplaceeditable[data-itemtype=tagname][data-itemid='+tagid+']').attr('data-value');
+                        fldset.append($('<input type="radio" name="maintag" id="combinetags_maintag_'+tagid+'" value="'+tagid+
+                            '"/><label for="combinetags_maintag_'+tagid+'">'+tagname+'</label><br>'));
+                    });
+                    var panel = new M.core.dialogue ({
+                        draggable: true,
+                        modal: true,
+                        closeButton: true,
+                        headerContent: s[0],
+                        bodyContent: el.html()
+                    });
+                    panel.show();
+                    $('#combinetags_form input[type=radio]').first().focus().prop('checked', true);
+                    $('#combinetags_form #combinetags_cancel').on('click', function() {
+                        panel.destroy();
+                    });
+                    $('#combinetags_form').on('submit', function() {
+                        tempElement.appendTo(form);
+                        var maintag = $('input[name=maintag]:checked', '#combinetags_form').val();
+                        $("<input type='hidden'/>").attr('name', 'maintag').attr('value', maintag).appendTo(form);
+                        form.submit();
+                        return false;
+                    });
+                });
+            });
+
+            // When user changes tag name to some name that already exists suggest to combine the tags.
+            $('body').on('updatefailed', '[data-inplaceeditable][data-itemtype=tagname]', function(e) {
+                var exception = e.exception; // The exception object returned by the callback.
+                var newvalue = e.newvalue; // The value that user tried to udpated the element to.
+                var tagid = $(e.target).attr('data-itemid');
+                if (exception.errorcode === 'namesalreadybeeingused') {
+                    e.preventDefault(); // This will prevent default error dialogue.
+                    str.get_strings([
+                        {key : 'nameuseddocombine', component : 'tag'},
+                        {key : 'yes'},
+                        {key : 'cancel'},
+                    ]).done(function(s) {
+                        notification.confirm(e.message, s[0], s[1], s[2], function() {
+                            window.location.href = window.location.href + "&newname=" + encodeURIComponent(newvalue) +
+                                "&tagid=" + encodeURIComponent(tagid) +
+                                '&action=renamecombine&sesskey=' + M.cfg.sesskey;
+                        });
+                    });
+                }
+            });
+
+            // Form for adding standard tags.
+            $('body').on('click', 'a[data-action=addstandardtag]', function(e) {
+                e.preventDefault();
+                str.get_strings([
+                    {key : 'addotags', component : 'tag'},
+                    {key : 'inputstandardtags', component : 'tag'},
+                    {key : 'continue'},
+                    {key : 'cancel'},
+                ]).done(function(s) {
+                    var el = $('<div><form id="addtags_form" class="form-inline" method="POST">' +
+                        '<input type="hidden" name="action" value="addstandardtag"/>' +
+                        '<input type="hidden" name="sesskey" value="' + M.cfg.sesskey + '"/>' +
+                        '<p><label for="id_tagslist">' + s[1] + '</label>' +
+                        '<input type="text" id="id_tagslist" name="tagslist"/></p>' +
+                        '<p class="mdl-align"><input type="submit" id="addtags_submit"/>' +
+                        '<input type="button" id="addtags_cancel"/></p>' +
+                        '</form></div>');
+                    el.find('#addtags_form').attr('action', window.location.href);
+                    el.find('#addtags_submit').attr('value', s[2]);
+                    el.find('#addtags_cancel').attr('value', s[3]);
+                    var panel = new M.core.dialogue ({
+                        draggable: true,
+                        modal: true,
+                        closeButton: true,
+                        headerContent: s[0],
+                        bodyContent: el.html()
+                    });
+                    panel.show();
+                    $('#addtags_form input[type=text]').focus();
+                    $('#addtags_form #addtags_cancel').on('click', function() {
+                        panel.destroy();
+                    });
+                });
+            });
         },
 
         /**
diff --git a/lib/amd/src/tooltip.js b/lib/amd/src/tooltip.js
new file mode 100644 (file)
index 0000000..7faaf95
--- /dev/null
@@ -0,0 +1,133 @@
+define(['jquery'], function($) {
+
+    /**
+     * Tooltip class.
+     *
+     * @param {String} selector The css selector for the node(s) to enhance with tooltips.
+     */
+    var Tooltip = function(selector) {
+        // Tooltip code matches: http://www.w3.org/WAI/PF/aria-practices/#tooltip
+        this._regionSelector = selector;
+
+        // For each node matching the selector - find an aria-describedby attribute pointing to an role="tooltip" element.
+
+        $(this._regionSelector).each(function(index, element) {
+            var tooltipId = $(element).attr('aria-describedby');
+            if (tooltipId) {
+                var tooltipele = document.getElementById(tooltipId);
+                if (tooltipele) {
+                    var correctRole = $(tooltipele).attr('role') == 'tooltip';
+
+                    if (correctRole) {
+                        $(tooltipele).hide();
+                        // Ensure the trigger for the tooltip is keyboard focusable.
+                        $(element).attr('tabindex', '0');
+                    }
+
+                    // Attach listeners.
+                    $(element).on('focus', this._handleFocus.bind(this));
+                    $(element).on('mouseover', this._handleMouseOver.bind(this));
+                    $(element).on('mouseout', this._handleMouseOut.bind(this));
+                    $(element).on('blur', this._handleBlur.bind(this));
+                    $(element).on('keydown', this._handleKeyDown.bind(this));
+                }
+            }
+        }.bind(this));
+    };
+
+    /** @type {String} Selector for the page region containing the user navigation. */
+    Tooltip.prototype._regionSelector = null;
+
+    /**
+     * Find the tooltip referred to by this element and show it.
+     *
+     * @param {Event} e
+     */
+    Tooltip.prototype._showTooltip = function(e) {
+        var triggerElement = $(e.target);
+        var tooltipId = triggerElement.attr('aria-describedby');
+        if (tooltipId) {
+            var tooltipele = $(document.getElementById(tooltipId));
+
+            tooltipele.show();
+            tooltipele.attr('aria-hidden', 'false');
+
+            if (!tooltipele.is('.tooltip')) {
+                // Change the markup to a bootstrap tooltip.
+                var inner = $('<div class="tooltip-inner"></div>');
+                inner.append(tooltipele.contents());
+                tooltipele.append(inner);
+                tooltipele.addClass('tooltip');
+                tooltipele.addClass('bottom');
+                tooltipele.append('<div class="tooltip-arrow"></div>');
+            }
+            var pos = triggerElement.offset();
+            pos.top += triggerElement.height() + 10;
+            $(tooltipele).offset(pos);
+        }
+    };
+
+    /**
+     * Find the tooltip referred to by this element and hide it.
+     *
+     * @param {Event} e
+     */
+    Tooltip.prototype._hideTooltip = function(e) {
+        var triggerElement = $(e.target);
+        var tooltipId = triggerElement.attr('aria-describedby');
+        if (tooltipId) {
+            var tooltipele = document.getElementById(tooltipId);
+
+            $(tooltipele).hide();
+            $(tooltipele).attr('aria-hidden', 'true');
+        }
+    };
+
+    /**
+     * Listener for focus events.
+     * @param {Event} e
+     */
+    Tooltip.prototype._handleFocus = function(e) {
+        this._showTooltip(e);
+    };
+
+    /**
+     * Listener for keydown events.
+     * @param {Event} e
+     */
+    Tooltip.prototype._handleKeyDown = function(e) {
+        if (e.which == 27) {
+            this._hideTooltip(e);
+        }
+    };
+
+    /**
+     * Listener for mouseover events.
+     * @param {Event} e
+     */
+    Tooltip.prototype._handleMouseOver = function(e) {
+        this._showTooltip(e);
+    };
+
+    /**
+     * Listener for mouseout events.
+     * @param {Event} e
+     */
+    Tooltip.prototype._handleMouseOut = function(e) {
+        var triggerElement = $(e.target);
+
+        if (!triggerElement.is(":focus")) {
+            this._hideTooltip(e);
+        }
+    };
+
+    /**
+     * Listener for blur events.
+     * @param {Event} e
+     */
+    Tooltip.prototype._handleBlur = function(e) {
+        this._hideTooltip(e);
+    };
+
+    return Tooltip;
+});
index 7c372c6..6c8b5de 100644 (file)
@@ -804,6 +804,19 @@ class behat_base extends Behat\MinkExtension\Context\RawMinkContext {
         }
     }
 
+    /**
+     * Converts HTML tags to line breaks to display the info in CLI
+     *
+     * @param string $html
+     * @return string
+     */
+    protected function get_debug_text($html) {
+
+        // Replacing HTML tags for new lines and keeping only the text.
+        $notags = preg_replace('/<+\s*\/*\s*([A-Z][A-Z0-9]*)\b[^>]*\/*\s*>*/i', "\n", $html);
+        return preg_replace("/(\n)+/s", "\n", $notags);
+    }
+
     /**
      * Helper function to execute api in a given context.
      *
index 1ab06c2..201831f 100644 (file)
@@ -166,7 +166,7 @@ function behat_clean_init_config() {
         'umaskpermissions', 'dbtype', 'dblibrary', 'dbhost', 'dbname', 'dbuser', 'dbpass', 'prefix',
         'dboptions', 'proxyhost', 'proxyport', 'proxytype', 'proxyuser', 'proxypassword',
         'proxybypass', 'theme', 'pathtogs', 'pathtodu', 'aspellpath', 'pathtodot', 'skiplangupgrade',
-        'altcacheconfigpath'
+        'altcacheconfigpath', 'pathtounoconv'
     ));
 
     // Add extra allowed settings.
index a06a451..95cbcfc 100644 (file)
@@ -135,8 +135,10 @@ class manager {
         global $DB;
 
         $record = self::record_from_adhoc_task($task);
-        // Schedule it immediately.
-        $record->nextruntime = time() - 1;
+        // Schedule it immediately if nextruntime not explicitly set.
+        if (!$task->get_next_run_time()) {
+            $record->nextruntime = time() - 1;
+        }
         $result = $DB->insert_record('task_adhoc', $record);
 
         return $result;
index 09902dd..912fe5a 100644 (file)
@@ -277,5 +277,18 @@ $definitions = array(
         'mode' => cache_store::MODE_REQUEST,
         'simplekeys' => true,
         'simpledata' => true
-    )
+    ),
+
+    // Caches tag index builder results.
+    'tagindexbuilder' => array(
+        'mode' => cache_store::MODE_SESSION,
+        'simplekeys' => true,
+        'simplevalues' => true,
+        'staticacceleration' => true,
+        'staticaccelerationsize' => 10,
+        'ttl' => 900, // 15 minutes.
+        'invalidationevents' => array(
+            'resettagindexbuilder',
+        ),
+    ),
 );
index e08f0e1..ead08a2 100644 (file)
@@ -428,6 +428,7 @@ $functions = array(
         'classpath'   => 'user/externallib.php',
         'description' => 'Retrieve users information for a specified unique field - If you want to do a user search, use core_user_get_users()',
         'type'        => 'read',
+        'ajax'        => true,
         'capabilities'=> 'moodle/user:viewdetails, moodle/user:viewhiddendetails, moodle/course:useremail, moodle/user:update',
     ),
 
index 80bb379..2dc6b31 100644 (file)
@@ -83,5 +83,7 @@ $tagareas = array(
     array(
         'itemtype' => 'course_modules', // Course modules.
         'component' => 'core',
+        'callback' => 'course_get_tagged_course_modules',
+        'callbackfile' => '/course/lib.php',
     ),
 );
index 6affc75..8964b9b 100644 (file)
@@ -4473,3 +4473,20 @@ function site_scale_used($scaleid, &$courses) {
     }
     return $return;
 }
+
+/**
+ * Returns detailed function information
+ *
+ * @deprecated since Moodle 3.1
+ * @param string|object $function name of external function or record from external_function
+ * @param int $strictness IGNORE_MISSING means compatible mode, false returned if record not found, debug message if more found;
+ *                        MUST_EXIST means throw exception if no record or multiple records found
+ * @return stdClass description or false if not found or exception thrown
+ * @since Moodle 2.0
+ */
+function external_function_info($function, $strictness=MUST_EXIST) {
+    debugging('external_function_info() is deprecated. Please use external_api::external_function_info() instead.',
+              DEBUG_DEVELOPER);
+    return external_api::external_function_info($function, $strictness);
+}
+
index 4efa43a..09ba0b7 100644 (file)
Binary files a/lib/editor/atto/yui/build/moodle-editor_atto-editor/moodle-editor_atto-editor-debug.js and b/lib/editor/atto/yui/build/moodle-editor_atto-editor/moodle-editor_atto-editor-debug.js differ
index b64c044..912122e 100644 (file)
Binary files a/lib/editor/atto/yui/build/moodle-editor_atto-editor/moodle-editor_atto-editor-min.js and b/lib/editor/atto/yui/build/moodle-editor_atto-editor/moodle-editor_atto-editor-min.js differ
index 3b9fe61..27f74d3 100644 (file)
Binary files a/lib/editor/atto/yui/build/moodle-editor_atto-editor/moodle-editor_atto-editor.js and b/lib/editor/atto/yui/build/moodle-editor_atto-editor/moodle-editor_atto-editor.js differ
index a549c80..4dd4ed4 100644 (file)
@@ -99,6 +99,14 @@ EditorAutosave.prototype = {
      */
     autosaveInstance: null,
 
+    /**
+     * Autosave Timer.
+     *
+     * @property autosaveTimer
+     * @type object
+     */
+    autosaveTimer: null,
+
     /**
      * Initialize the autosave process
      *
@@ -183,7 +191,7 @@ EditorAutosave.prototype = {
         // Now setup the timer for periodic saves.
 
         var delay = parseInt(this.get('autosaveFrequency'), 10) * 1000;
-        Y.later(delay, this, this.saveDraft, false, true);
+        this.autosaveTimer = Y.later(delay, this, this.saveDraft, false, true);
 
         // Now setup the listener for form submission.
         form = this.textarea.ancestor('form');
@@ -247,6 +255,12 @@ EditorAutosave.prototype = {
      */
     saveDraft: function() {
         var url, params;
+
+        if (!this.editor.getDOMNode()) {
+            // Stop autosaving if the editor was removed from the page.
+            this.autosaveTimer.cancel();
+            return;
+        }
         // Only copy the text from the div to the textarea if the textarea is not currently visible.
         if (!this.editor.get('hidden')) {
             this.updateOriginal();
index 2e2a663..4de979b 100644 (file)
@@ -386,7 +386,6 @@ class core_external extends external_api {
         if (!$tmpl || !($tmpl instanceof \core\output\inplace_editable)) {
             throw new \moodle_exception('inplaceeditableerror');
         }
-        $PAGE->set_context(null); // To prevent warning if context was not set in the callback.
         return $tmpl->export_for_template($PAGE->get_renderer('core'));
     }
 
@@ -462,7 +461,7 @@ class core_external extends external_api {
             ]);
 
         $context = \context::instance_by_id($contextid);
-        $PAGE->set_context($context);
+        self::validate_context($context);
 
         return \core\notification::fetch_as_array($PAGE->get_renderer('core'));
     }
index 002d572..7c0e838 100644 (file)
 
 defined('MOODLE_INTERNAL') || die();
 
-/**
- * Returns detailed function information
- *
- * @param string|object $function name of external function or record from external_function
- * @param int $strictness IGNORE_MISSING means compatible mode, false returned if record not found, debug message if more found;
- *                        MUST_EXIST means throw exception if no record or multiple records found
- * @return stdClass description or false if not found or exception thrown
- * @since Moodle 2.0
- */
-function external_function_info($function, $strictness=MUST_EXIST) {
-    global $DB, $CFG;
-
-    if (!is_object($function)) {
-        if (!$function = $DB->get_record('external_functions', array('name'=>$function), '*', $strictness)) {
-            return false;
-        }
-    }
-
-    // First try class autoloading.
-    if (!class_exists($function->classname)) {
-        // Fallback to explicit include of externallib.php.
-        $function->classpath = empty($function->classpath) ? core_component::get_component_directory($function->component).'/externallib.php' : $CFG->dirroot.'/'.$function->classpath;
-        if (!file_exists($function->classpath)) {
-            throw new coding_exception('Cannot find file with external function implementation: ' . $function->classname);
-        }
-        require_once($function->classpath);
-        if (!class_exists($function->classname)) {
-            throw new coding_exception('Cannot find external class');
-        }
-    }
-
-    $function->ajax_method = $function->methodname.'_is_allowed_from_ajax';
-    $function->parameters_method = $function->methodname.'_parameters';
-    $function->returns_method    = $function->methodname.'_returns';
-    $function->deprecated_method = $function->methodname.'_is_deprecated';
-
-    // make sure the implementaion class is ok
-    if (!method_exists($function->classname, $function->methodname)) {
-        throw new coding_exception('Missing implementation method of '.$function->classname.'::'.$function->methodname);
-    }
-    if (!method_exists($function->classname, $function->parameters_method)) {
-        throw new coding_exception('Missing parameters description');
-    }
-    if (!method_exists($function->classname, $function->returns_method)) {
-        throw new coding_exception('Missing returned values description');
-    }
-    if (method_exists($function->classname, $function->deprecated_method)) {
-        if (call_user_func(array($function->classname, $function->deprecated_method)) === true) {
-            $function->deprecated = true;
-        }
-    }
-    $function->allowed_from_ajax = false;
-
-    // fetch the parameters description
-    $function->parameters_desc = call_user_func(array($function->classname, $function->parameters_method));
-    if (!($function->parameters_desc instanceof external_function_parameters)) {
-        throw new coding_exception('Invalid parameters description');
-    }
-
-    // fetch the return values description
-    $function->returns_desc = call_user_func(array($function->classname, $function->returns_method));
-    // null means void result or result is ignored
-    if (!is_null($function->returns_desc) and !($function->returns_desc instanceof external_description)) {
-        throw new coding_exception('Invalid return description');
-    }
-
-    //now get the function description
-    //TODO MDL-31115 use localised lang pack descriptions, it would be nice to have
-    //      easy to understand descriptions in admin UI,
-    //      on the other hand this is still a bit in a flux and we need to find some new naming
-    //      conventions for these descriptions in lang packs
-    $function->description = null;
-    $servicesfile = core_component::get_component_directory($function->component).'/db/services.php';
-    if (file_exists($servicesfile)) {
-        $functions = null;
-        include($servicesfile);
-        if (isset($functions[$function->name]['description'])) {
-            $function->description = $functions[$function->name]['description'];
-        }
-        if (isset($functions[$function->name]['testclientpath'])) {
-            $function->testclientpath = $functions[$function->name]['testclientpath'];
-        }
-        if (isset($functions[$function->name]['type'])) {
-            $function->type = $functions[$function->name]['type'];
-        }
-        if (isset($functions[$function->name]['ajax'])) {
-            $function->allowed_from_ajax = $functions[$function->name]['ajax'];
-        } else if (method_exists($function->classname, $function->ajax_method)) {
-            if (call_user_func(array($function->classname, $function->ajax_method)) === true) {
-                debugging('External function ' . $function->ajax_method . '() function is deprecated.' .
-                          'Set ajax=>true in db/service.php instead.', DEBUG_DEVELOPER);
-                $function->allowed_from_ajax = true;
-            }
-        }
-        if (isset($functions[$function->name]['loginrequired'])) {
-            $function->loginrequired = $functions[$function->name]['loginrequired'];
-        } else {
-            $function->loginrequired = true;
-        }
-    }
-
-    return $function;
-}
-
 /**
  * Exception indicating user is not allowed to use external function in the current context.
  *
@@ -161,6 +57,203 @@ class external_api {
     /** @var stdClass context where the function calls will be restricted */
     private static $contextrestriction;
 
+    /**
+     * Returns detailed function information
+     *
+     * @param string|object $function name of external function or record from external_function
+     * @param int $strictness IGNORE_MISSING means compatible mode, false returned if record not found, debug message if more found;
+     *                        MUST_EXIST means throw exception if no record or multiple records found
+     * @return stdClass description or false if not found or exception thrown
+     * @since Moodle 2.0
+     */
+    public static function external_function_info($function, $strictness=MUST_EXIST) {
+        global $DB, $CFG;
+
+        if (!is_object($function)) {
+            if (!$function = $DB->get_record('external_functions', array('name' => $function), '*', $strictness)) {
+                return false;
+            }
+        }
+
+        // First try class autoloading.
+        if (!class_exists($function->classname)) {
+            // Fallback to explicit include of externallib.php.
+            if (empty($function->classpath)) {
+                $function->classpath = core_component::get_component_directory($function->component).'/externallib.php';
+            } else {
+                $function->classpath = $CFG->dirroot.'/'.$function->classpath;
+            }
+            if (!file_exists($function->classpath)) {
+                throw new coding_exception('Cannot find file with external function implementation');
+            }
+            require_once($function->classpath);
+            if (!class_exists($function->classname)) {
+                throw new coding_exception('Cannot find external class');
+            }
+        }
+
+        $function->ajax_method = $function->methodname.'_is_allowed_from_ajax';
+        $function->parameters_method = $function->methodname.'_parameters';
+        $function->returns_method    = $function->methodname.'_returns';
+        $function->deprecated_method = $function->methodname.'_is_deprecated';
+
+        // Make sure the implementaion class is ok.
+        if (!method_exists($function->classname, $function->methodname)) {
+            throw new coding_exception('Missing implementation method of '.$function->classname.'::'.$function->methodname);
+        }
+        if (!method_exists($function->classname, $function->parameters_method)) {
+            throw new coding_exception('Missing parameters description');
+        }
+        if (!method_exists($function->classname, $function->returns_method)) {
+            throw new coding_exception('Missing returned values description');
+        }
+        if (method_exists($function->classname, $function->deprecated_method)) {
+            if (call_user_func(array($function->classname, $function->deprecated_method)) === true) {
+                $function->deprecated = true;
+            }
+        }
+        $function->allowed_from_ajax = false;
+
+        // Fetch the parameters description.
+        $function->parameters_desc = call_user_func(array($function->classname, $function->parameters_method));
+        if (!($function->parameters_desc instanceof external_function_parameters)) {
+            throw new coding_exception('Invalid parameters description');
+        }
+
+        // Fetch the return values description.
+        $function->returns_desc = call_user_func(array($function->classname, $function->returns_method));
+        // Null means void result or result is ignored.
+        if (!is_null($function->returns_desc) and !($function->returns_desc instanceof external_description)) {
+            throw new coding_exception('Invalid return description');
+        }
+
+        // Now get the function description.
+
+        // TODO MDL-31115 use localised lang pack descriptions, it would be nice to have
+        // easy to understand descriptions in admin UI,
+        // on the other hand this is still a bit in a flux and we need to find some new naming
+        // conventions for these descriptions in lang packs.
+        $function->description = null;
+        $servicesfile = core_component::get_component_directory($function->component).'/db/services.php';
+        if (file_exists($servicesfile)) {
+            $functions = null;
+            include($servicesfile);
+            if (isset($functions[$function->name]['description'])) {
+                $function->description = $functions[$function->name]['description'];
+            }
+            if (isset($functions[$function->name]['testclientpath'])) {
+                $function->testclientpath = $functions[$function->name]['testclientpath'];
+            }
+            if (isset($functions[$function->name]['type'])) {
+                $function->type = $functions[$function->name]['type'];
+            }
+            if (isset($functions[$function->name]['ajax'])) {
+                $function->allowed_from_ajax = $functions[$function->name]['ajax'];
+            } else if (method_exists($function->classname, $function->ajax_method)) {
+                if (call_user_func(array($function->classname, $function->ajax_method)) === true) {
+                    debugging('External function ' . $function->ajax_method . '() function is deprecated.' .
+                              'Set ajax=>true in db/service.php instead.', DEBUG_DEVELOPER);
+                    $function->allowed_from_ajax = true;
+                }
+            }
+            if (isset($functions[$function->name]['loginrequired'])) {
+                $function->loginrequired = $functions[$function->name]['loginrequired'];
+            } else {
+                $function->loginrequired = true;
+            }
+        }
+
+        return $function;
+    }
+
+    /**
+     * Call an external function validating all params/returns correctly.
+     *
+     * Note that an external function may modify the state of the current page, so this wrapper
+     * saves and restores tha PAGE and COURSE global variables before/after calling the external function.
+     *
+     * @param string $function A webservice function name.
+     * @param array $args Params array (named params)
+     * @param boolean $ajaxonly If true, an extra check will be peformed to see if ajax is required.
+     * @return array containing keys for error (bool), exception and data.
+     */
+    public static function call_external_function($function, $args, $ajaxonly=false) {
+        global $PAGE, $COURSE, $CFG, $SITE;
+
+        require_once($CFG->libdir . "/pagelib.php");
+
+        $externalfunctioninfo = self::external_function_info($function);
+
+        $currentpage = $PAGE;
+        $currentcourse = $COURSE;
+        $response = array();
+
+        try {
+            // Taken straight from from setup.php.
+            if (!empty($CFG->moodlepageclass)) {
+                if (!empty($CFG->moodlepageclassfile)) {
+                    require_once($CFG->moodlepageclassfile);
+                }
+                $classname = $CFG->moodlepageclass;
+            } else {
+                $classname = 'moodle_page';
+            }
+            $PAGE = new $classname();
+            $COURSE = clone($SITE);
+
+            if ($ajaxonly && !$externalfunctioninfo->allowed_from_ajax) {
+                throw new moodle_exception('servicenotavailable', 'webservice');
+            }
+
+            // Do not allow access to write or delete webservices as a public user.
+            if ($externalfunctioninfo->loginrequired) {
+                if (defined('NO_MOODLE_COOKIES') && NO_MOODLE_COOKIES && !PHPUNIT_TEST) {
+                    throw new moodle_exception('servicenotavailable', 'webservice');
+                }
+                if (!isloggedin()) {
+                    throw new moodle_exception('servicenotavailable', 'webservice');
+                } else {
+                    require_sesskey();
+                }
+            }
+
+            // Validate params, this also sorts the params properly, we need the correct order in the next part.
+            $callable = array($externalfunctioninfo->classname, 'validate_parameters');
+            $params = call_user_func($callable,
+                                     $externalfunctioninfo->parameters_desc,
+                                     $args);
+
+            // Execute - gulp!
+            $callable = array($externalfunctioninfo->classname, $externalfunctioninfo->methodname);
+            $result = call_user_func_array($callable,
+                                           array_values($params));
+
+            // Validate the return parameters.
+            if ($externalfunctioninfo->returns_desc !== null) {
+                $callable = array($externalfunctioninfo->classname, 'clean_returnvalue');
+                $result = call_user_func($callable, $externalfunctioninfo->returns_desc, $result);
+            }
+
+            $response['error'] = false;
+            $response['data'] = $result;
+        } catch (Exception $e) {
+            $exception = get_exception_info($e);
+            unset($exception->a);
+            if (!debugging('', DEBUG_DEVELOPER)) {
+                unset($exception->debuginfo);
+                unset($exception->backtrace);
+            }
+            $response['error'] = true;
+            $response['exception'] = $exception;
+            // Do not process the remaining requests.
+        }
+
+        $PAGE = $currentpage;
+        $COURSE = $currentcourse;
+
+        return $response;
+    }
+
     /**
      * Set context restriction for all following subsequent function calls.
      *
@@ -359,7 +452,7 @@ class external_api {
      * @since Moodle 2.0
      */
     public static function validate_context($context) {
-        global $CFG;
+        global $CFG, $PAGE;
 
         if (empty($context)) {
             throw new invalid_parameter_exception('Context does not exist');
@@ -382,10 +475,10 @@ class external_api {
             }
         }
 
-        if ($context->contextlevel >= CONTEXT_COURSE) {
-            list($context, $course, $cm) = get_context_info_array($context->id);
-            require_login($course, false, $cm, false, true);
-        }
+        $PAGE->reset_theme_and_output();
+        list($unused, $course, $cm) = get_context_info_array($context->id);
+        require_login($course, false, $cm, false, true);
+        $PAGE->set_context($context);
     }
 
     /**
@@ -792,16 +885,34 @@ function external_format_string($str, $contextid, $striplinks = true, $options =
  * The caller can change the format (raw, filter, file, fileurl) with the external_settings singleton
  * All web service servers must set this singleton when parsing the $_GET and $_POST.
  *
+ * <pre>
+ * Options are the same that in {@link format_text()} with some changes in defaults to provide backwards compatibility:
+ *      trusted     :   If true the string won't be cleaned. Default false.
+ *      noclean     :   If true the string won't be cleaned only if trusted is also true. Default false.
+ *      nocache     :   If true the string will not be cached and will be formatted every call. Default false.
+ *      filter      :   If true the string will be run through applicable filters as well. Default (different from format_text)
+ *                      got form settings.
+ *      para        :   If true then the returned string will be wrapped in div tags. Default (different from format_text) false.
+ *                      Default changed because div tags are not commonly needed.
+ *      newlines    :   If true then lines newline breaks will be converted to HTML newline breaks. Default true.
+ *      context     :   Not used! Using contextid parameter instead.
+ *      overflowdiv :   If set to true the formatted text will be encased in a div with the class no-overflow before being
+ *                      returned. Default false.
+ *      allowid     :   If true then id attributes will not be removed, even when using htmlpurifier. Default (different from
+ *                      format_text) true. Default changed id attributes are commonly needed.
+ * </pre>
+ *
  * @param string $text The content that may contain ULRs in need of rewriting.
  * @param int $textformat The text format.
  * @param int $contextid This parameter and the next two identify the file area to use.
  * @param string $component
  * @param string $filearea helps identify the file area.
  * @param int $itemid helps identify the file area.
+ * @param object/array $options text formatting options
  * @return array text + textformat
  * @since Moodle 2.3
  */
-function external_format_text($text, $textformat, $contextid, $component, $filearea, $itemid) {
+function external_format_text($text, $textformat, $contextid, $component, $filearea, $itemid, $options = null) {
     global $CFG;
 
     // Get settings (singleton).
@@ -813,8 +924,23 @@ function external_format_text($text, $textformat, $contextid, $component, $filea
     }
 
     if (!$settings->get_raw()) {
-        $context = context::instance_by_id($contextid);
-        $text = format_text($text, $textformat, array('para' => false, 'filter' => $settings->get_filter(), 'context' => $context));
+        $options = (array)$options;
+
+        // If context is passed in options, check that is the same to show a debug message.
+        if (isset($options['context'])) {
+            if ((is_object($options['context']) && $options['context']->id != $contextid)
+                    || (!is_object($options['context']) && $options['context'] != $contextid)) {
+                debugging('Different contexts found in external_format_text parameters. $options[\'context\'] not allowed.
+                    Using $contextid parameter...', DEBUG_DEVELOPER);
+            }
+        }
+
+        $options['filter'] = isset($options['filter']) ? $options['filter'] : $settings->get_filter();
+        $options['para'] = isset($options['para']) ? $options['para'] : false;
+        $options['context'] = context::instance_by_id($contextid);
+        $options['allowid'] = isset($options['allowid']) ? $options['allowid'] : true;
+
+        $text = format_text($text, $textformat, $options);
         $textformat = FORMAT_HTML; // Once converted to html (from markdown, plain... lets inform consumer this is already HTML).
     }
 
index 9290814..6ccb84e 100644 (file)
@@ -53,6 +53,9 @@ class file_storage {
     private $dirpermissions;
     /** @var int Permissions for new files */
     private $filepermissions;
+    /** @var array List of formats supported by unoconv */
+    private $unoconvformats;
+
 
     /**
      * Constructor - do not use directly use {@link get_file_storage()} call instead.
@@ -156,6 +159,129 @@ class file_storage {
         return $storedfile;
     }
 
+    /**
+     * Get converted document.
+     *
+     * Get an alternate version of the specified document, if it is possible to convert.
+     *
+     * @param stored_file $file the file we want to preview
+     * @param string $format The desired format - e.g. 'pdf'. Formats are specified by file extension.
+     * @return stored_file|bool false if unable to create the conversion, stored file otherwise
+     */
+    public function get_converted_document(stored_file $file, $format) {
+
+        $context = context_system::instance();
+        $path = '/' . $format . '/';
+        $conversion = $this->get_file($context->id, 'core', 'documentconversion', 0, $path, $file->get_contenthash());
+
+        if (!$conversion) {
+            $conversion = $this->create_converted_document($file, $format);
+            if (!$conversion) {
+                return false;
+            }
+        }
+
+        return $conversion;
+    }
+
+    /**
+     * Verify the format is supported.
+     *
+     * @param string $format The desired format - e.g. 'pdf'. Formats are specified by file extension.
+     * @return bool - True if the format is supported for input.
+     */
+    protected function is_format_supported_by_unoconv($format) {
+        global $CFG;
+
+        if (!isset($this->unoconvformats)) {
+            // Ask unoconv for it's list of supported document formats.
+            $cmd = escapeshellcmd(trim($CFG->pathtounoconv)) . ' --show';
+            $pipes = array();
+            $pipesspec = array(2 => array('pipe', 'w'));
+            $proc = proc_open($cmd, $pipesspec, $pipes);
+            $programoutput = stream_get_contents($pipes[2]);
+            fclose($pipes[2]);
+            proc_close($proc);
+            $matches = array();
+            preg_match_all('/\[\.(.*)\]/', $programoutput, $matches);
+
+            $this->unoconvformats = $matches[1];
+            $this->unoconvformats = array_unique($this->unoconvformats);
+        }
+
+        $sanitized = trim(core_text::strtolower($format));
+        return in_array($sanitized, $this->unoconvformats);
+    }
+
+
+    /**
+     * Perform a file format conversion on the specified document.
+     *
+     * @param stored_file $file the file we want to preview
+     * @param string $format The desired format - e.g. 'pdf'. Formats are specified by file extension.
+     * @return stored_file|bool false if unable to create the conversion, stored file otherwise
+     */
+    protected function create_converted_document(stored_file $file, $format) {
+        global $CFG;
+
+        if (empty($CFG->pathtounoconv) || !is_executable(trim($CFG->pathtounoconv))) {
+            // No conversions are possible, sorry.
+            return false;
+        }
+
+        $fileextension = core_text::strtolower(pathinfo($file->get_filename(), PATHINFO_EXTENSION));
+        if (!self::is_format_supported_by_unoconv($fileextension)) {
+            return false;
+        }
+
+        if (!self::is_format_supported_by_unoconv($format)) {
+            return false;
+        }
+
+        // Copy the file to the local tmp dir.
+        $tmp = make_request_directory();
+        $localfilename = $file->get_filename();
+        // Safety.
+        $localfilename = clean_param($localfilename, PARAM_FILE);
+
+        $filename = $tmp . '/' . $localfilename;
+        $file->copy_content_to($filename);
+
+        $newtmpfile = pathinfo($filename, PATHINFO_FILENAME) . '.' . $format;
+
+        // Safety.
+        $newtmpfile = $tmp . '/' . clean_param($newtmpfile, PARAM_FILE);
+
+        $cmd = escapeshellcmd(trim($CFG->pathtounoconv)) . ' ' .
+               escapeshellarg('-f') . ' ' .
+               escapeshellarg($format) . ' ' .
+               escapeshellarg('-o') . ' ' .
+               escapeshellarg($newtmpfile) . ' ' .
+               escapeshellarg($filename);
+
+        $e = file_exists($filename);
+        $output = null;
+        $currentdir = getcwd();
+        chdir($tmp);
+        $result = exec($cmd, $output);
+        chdir($currentdir);
+        if (!file_exists($newtmpfile)) {
+            return false;
+        }
+
+        $context = context_system::instance();
+        $record = array(
+            'contextid' => $context->id,
+            'component' => 'core',
+            'filearea'  => 'documentconversion',
+            'itemid'    => 0,
+            'filepath'  => '/' . $format . '/',
+            'filename'  => $file->get_contenthash(),
+        );
+
+        return $this->create_file_from_pathname($record, $newtmpfile);
+    }
+
     /**
      * Returns an image file that represent the given stored file as a preview
      *
@@ -2282,6 +2408,26 @@ class file_storage {
         $rs->close();
         mtrace('done.');
 
+        // Remove orphaned converted files (that is files in the core documentconversion filearea without
+        // the existing original file).
+        mtrace('Deleting orphaned document conversion files... ', '');
+        cron_trace_time_and_memory();
+        $sql = "SELECT p.*
+                  FROM {files} p
+             LEFT JOIN {files} o ON (p.filename = o.contenthash)
+                 WHERE p.contextid = ? AND p.component = 'core' AND p.filearea = 'documentconversion' AND p.itemid = 0
+                       AND o.id IS NULL";
+        $syscontext = context_system::instance();
+        $rs = $DB->get_recordset_sql($sql, array($syscontext->id));
+        foreach ($rs as $orphan) {
+            $file = $this->get_file_instance($orphan);
+            if (!$file->is_directory()) {
+                $file->delete();
+            }
+        }
+        $rs->close();
+        mtrace('done.');
+
         // remove trash pool files once a day
         // if you want to disable purging of trash put $CFG->fileslastcleanup=time(); into config.php
         if (empty($CFG->fileslastcleanup) or $CFG->fileslastcleanup < time() - 60*60*24) {
index 5cf63c2..1f2dac3 100644 (file)
@@ -50,6 +50,8 @@ class MoodleQuickForm_autocomplete extends MoodleQuickForm_select {
     protected $casesensitive = false;
     /** @var bool $showsuggestions Show suggestions by default - but this can be turned off. */
     protected $showsuggestions = true;
+    /** @var string $noselectionstring String that is shown when there are no selections. */
+    protected $noselectionstring = '';
 
     /**
      * constructor
@@ -79,6 +81,12 @@ class MoodleQuickForm_autocomplete extends MoodleQuickForm_select {
             $this->placeholder = $attributes['placeholder'];
             unset($attributes['placeholder']);
         }
+        $this->noselectionstring = get_string('noselection', 'form');
+        if (isset($attributes['noselectionstring'])) {
+            $this->noselectionstring = $attributes['noselectionstring'];
+            unset($attributes['noselectionstring']);
+        }
+
         if (isset($attributes['ajax'])) {
             $this->ajax = $attributes['ajax'];
             unset($attributes['ajax']);
@@ -114,7 +122,7 @@ class MoodleQuickForm_autocomplete extends MoodleQuickForm_select {
         $this->_generateId();
         $id = $this->getAttribute('id');
         $PAGE->requires->js_call_amd('core/form-autocomplete', 'enhance', $params = array('#' . $id, $this->tags, $this->ajax,
-            $this->placeholder, $this->casesensitive, $this->showsuggestions));
+            $this->placeholder, $this->casesensitive, $this->showsuggestions, $this->noselectionstring));
 
         return parent::toHTML();
     }
index 9121447..72e76db 100644 (file)
@@ -54,6 +54,11 @@ class MoodleQuickForm_course extends MoodleQuickForm_autocomplete {
      */
     protected $requiredcapabilities = array();
 
+    /**
+     * @var bool $limittoenrolled Only allow enrolled courses.
+     */
+    protected $limittoenrolled = false;
+
     /**
      * Constructor
      *
@@ -78,15 +83,25 @@ class MoodleQuickForm_course extends MoodleQuickForm_autocomplete {
         if (isset($options['requiredcapabilities'])) {
             $this->requiredcapabilities = $options['requiredcapabilities'];
         }
+        if (isset($options['limittoenrolled'])) {
+            $this->limittoenrolled = $options['limittoenrolled'];
+        }
 
         $validattributes = array(
             'ajax' => 'core/form-course-selector',
             'data-requiredcapabilities' => implode(',', $this->requiredcapabilities),
-            'data-exclude' => implode(',', $this->exclude)
+            'data-exclude' => implode(',', $this->exclude),
+            'data-limittoenrolled' => (int)$this->limittoenrolled
         );
         if ($this->multiple) {
             $validattributes['multiple'] = 'multiple';
         }
+        if (isset($options['noselectionstring'])) {
+            $validattributes['noselectionstring'] = $options['noselectionstring'];
+        }
+        if (isset($options['placeholder'])) {
+            $validattributes['placeholder'] = $options['placeholder'];
+        }
 
         parent::__construct($elementname, $elementlabel, array(), $validattributes);
     }
index 8218293..3aa71e4 100644 (file)
@@ -64,6 +64,7 @@ Feature: Using the activity grade form element
     And I press "Save and display"
     And I should not see "You must choose whether to rescale existing grades or not"
 
+  @javascript
   Scenario: Attempting to change the scale when grades already exist
     Given I log in as "admin"
     And I navigate to "Scales" node in "Site administration > Grades"
@@ -88,12 +89,12 @@ Feature: Using the activity grade form element
       | grade[modgrade_scale] | ABCDEF |
     And I follow "Course 1"
     And I follow "Test assignment name"
-    And I follow "View/grade all submissions"
-    And I click on "Grade Student 1" "link" in the "Student 1" "table_row"
+    And I follow "View all submissions"
+    And I click on "Grade" "link" in the "Student 1" "table_row"
     And I set the field "Grade" to "C"
     And I press "Save changes"
-    And I press "Continue"
-    And I click on "Edit settings" "link" in the "Administration" "block"
+    And I press "Ok"
+    And I click on "Edit settings" "link"
     When I expand all fieldsets
     Then I should see "Some grades have already been awarded, so the grade type and scale cannot be changed"
 
@@ -132,6 +133,7 @@ Feature: Using the activity grade form element
     And I press "Save and display"
     And I should see "You cannot change the maximum grade when grades already exist for an activity with ratings"
 
+  @javascript
   Scenario: Attempting to change the maximum grade when no rescaling option has been chosen
     Given I log in as "teacher1"
     And I follow "Course 1"
@@ -141,14 +143,11 @@ Feature: Using the activity grade form element
       | Description | Test assignment description |
     And I follow "Course 1"
     And I follow "Test assignment name"
-    And I follow "View/grade all submissions"
-    And I click on "Grade Student 1" "link" in the "Student 1" "table_row"
+    And I follow "View all submissions"
+    And I click on "Grade" "link" in the "Student 1" "table_row"
     And I set the field "Grade out of 100" to "50"
     And I press "Save changes"
-    And I press "Continue"
-    And I click on "Edit settings" "link" in the "Administration" "block"
+    And I press "Ok"
+    And I click on "Edit settings" "link"
     When I expand all fieldsets
     Then I should see "Some grades have already been awarded, so the grade type cannot be changed. If you wish to change the maximum grade, you must first choose whether or not to rescale existing grades."
-    And I set the field "Maximum grade" to "50"
-    And I press "Save and display"
-    And I should see "You must choose whether to rescale existing grades or not"
index 5b79091..f011b23 100644 (file)
@@ -131,6 +131,9 @@ abstract class moodleform {
     /** @var array globals workaround */
     protected $_customdata;
 
+    /** @var array submitted form data when using mforms with ajax */
+    protected $_ajaxformdata;
+
     /** @var object definition_after_data executed flag */
     protected $_definition_finalized = false;
 
@@ -156,8 +159,10 @@ abstract class moodleform {
      *               it if you don't need to as the target attribute is deprecated in xhtml strict.
      * @param mixed $attributes you can pass a string of html attributes here or an array.
      * @param bool $editable
+     * @param array $ajaxformdata Forms submitted via ajax, must pass their data here, instead of relying on _GET and _POST.
      */
-    public function __construct($action=null, $customdata=null, $method='post', $target='', $attributes=null, $editable=true) {
+    public function __construct($action=null, $customdata=null, $method='post', $target='', $attributes=null, $editable=true,
+                                $ajaxformdata=null) {
         global $CFG, $FULLME;
         // no standard mform in moodle should allow autocomplete with the exception of user signup
         if (empty($attributes)) {
@@ -170,6 +175,7 @@ abstract class moodleform {
             }
         }
 
+
         if (empty($action)){
             // do not rely on PAGE->url here because dev often do not setup $actualurl properly in admin_externalpage_setup()
             $action = strip_querystring($FULLME);
@@ -183,6 +189,7 @@ abstract class moodleform {
         // Assign custom data first, so that get_form_identifier can use it.
         $this->_customdata = $customdata;
         $this->_formname = $this->get_form_identifier();
+        $this->_ajaxformdata = $ajaxformdata;
 
         $this->_form = new MoodleQuickForm($this->_formname, $method, $action, $target, $attributes);
         if (!$editable){
@@ -272,7 +279,9 @@ abstract class moodleform {
      */
     function _process_submission($method) {
         $submission = array();
-        if ($method == 'post') {
+        if (!empty($this->_ajaxformdata)) {
+            $submission = $this->_ajaxformdata;
+        } else if ($method == 'post') {
             if (!empty($_POST)) {
                 $submission = $_POST;
             }
index 44dc966..ff73eb3 100644 (file)
@@ -448,7 +448,11 @@ define('MOD_ARCHETYPE_ASSIGNMENT', 2);
 /** System (not user-addable) module archetype */
 define('MOD_ARCHETYPE_SYSTEM', 3);
 
-/** Return this from modname_get_types callback to use default display in activity chooser */
+/**
+ * Return this from modname_get_types callback to use default display in activity chooser.
+ * Deprecated, will be removed in 3.5, TODO MDL-53697.
+ * @deprecated since Moodle 3.1
+ */
 define('MOD_SUBTYPE_NO_CHILDREN', 'modsubtypenochildren');
 
 /**
index 33466f5..acb6d31 100644 (file)
@@ -981,7 +981,6 @@ class moodle_page {
             }
             return;
         }
-
         // Ideally we should set context only once.
         if (isset($this->_context) && $context->id !== $this->_context->id) {
             $current = $this->_context->contextlevel;
@@ -993,11 +992,7 @@ class moodle_page {
             } else {
                 // We do not want devs to do weird switching of context levels on the fly because we might have used
                 // the context already such as in text filter in page title.
-                // This is explicitly allowed for webservices though which may
-                // call "external_api::validate_context on many contexts in a single request.
-                if (!WS_SERVER) {
-                    debugging("Coding problem: unsupported modification of PAGE->context from {$current} to {$context->contextlevel}");
-                }
+                debugging("Coding problem: unsupported modification of PAGE->context from {$current} to {$context->contextlevel}");
             }
         }
 
@@ -1560,6 +1555,24 @@ class moodle_page {
         $this->_wherethemewasinitialised = debug_backtrace();
     }
 
+    /**
+     * Reset the theme and output for a new context. This only makes sense from
+     * external::validate_context(). Do not cheat.
+     *
+     * @return string the name of the theme that should be used on this page.
+     */
+    public function reset_theme_and_output() {
+        global $COURSE, $SITE;
+
+        $COURSE = clone($SITE);
+        $this->_theme = null;
+        $this->_wherethemewasinitialised = null;
+        $this->_course = null;
+        $this->_cm = null;
+        $this->_module = null;
+        $this->_context = null;
+    }
+
     /**
      * Work out the theme this page should use.
      *
index 16bad00..aff8506 100644 (file)
@@ -185,7 +185,8 @@ $CFG->dboptions = isset($CFG->phpunit_dboptions) ? $CFG->phpunit_dboptions : $CF
 $allowed = array('wwwroot', 'dataroot', 'dirroot', 'admin', 'directorypermissions', 'filepermissions',
                  'dbtype', 'dblibrary', 'dbhost', 'dbname', 'dbuser', 'dbpass', 'prefix', 'dboptions',
                  'proxyhost', 'proxyport', 'proxytype', 'proxyuser', 'proxypassword', 'proxybypass', // keep proxy settings from config.php
-                 'altcacheconfigpath', 'pathtogs', 'pathtodu', 'aspellpath', 'pathtodot'
+                 'altcacheconfigpath', 'pathtogs', 'pathtodu', 'aspellpath', 'pathtodot',
+                 'pathtounoconv'
                 );
 $productioncfg = (array)$CFG;
 $CFG = new stdClass();
index 8eb5ec4..9f238d2 100644 (file)
     * multiple True if this field allows multiple selections
     * selectionId The dom id of the current selection list.
     * items List of items with label and value fields.
+    * noSelectionString String to use when no items are selected
 
     Example context (json):
     { "multiple": true, "selectionId": 1, "items": [
         { "label": "Item label with <strong>tags</strong>", "value": "5" },
         { "label": "Another item label with <strong>tags</strong>", "value": "4" }
-    ]}
+    ], "noSelectionString": "No selection" }
 }}
 <div class="form-autocomplete-selection {{#multiple}}form-autocomplete-multiple{{/multiple}}" id="{{selectionId}}" role="list" aria-atomic="true" {{#multiple}}tabindex="0" aria-multiselectable="true"{{/multiple}}>
 <span class="accesshide">{{#str}}selecteditems, form{{/str}}</span>
@@ -44,7 +45,7 @@
         </span>
     {{/items}}
     {{^items}}
-        <span>{{#str}}noselection,form{{/str}}</span>
+        <span>{{noSelectionString}}</span>
     {{/items}}
 </div>
 </div>
index 174e54c..1c4d70a 100644 (file)
@@ -37,34 +37,76 @@ require_once(__DIR__ . '/fixtures/task_fixtures.php');
  */
 class core_adhoc_task_testcase extends advanced_testcase {
 
-    public function test_get_next_adhoc_task() {
+    /**
+     * Test basic adhoc task execution.
+     */
+    public function test_get_next_adhoc_task_now() {
         $this->resetAfterTest(true);
+
         // Create an adhoc task.
         $task = new \core\task\adhoc_test_task();
 
         // Queue it.
-        $task = \core\task\manager::queue_adhoc_task($task);
+        \core\task\manager::queue_adhoc_task($task);
 
         $now = time();
         // Get it from the scheduler.
         $task = \core\task\manager::get_next_adhoc_task($now);
-        $this->assertNotNull($task);
+        $this->assertInstanceOf('\\core\\task\\adhoc_test_task', $task);
         $task->execute();
+        \core\task\manager::adhoc_task_complete($task);
+    }
 
-        \core\task\manager::adhoc_task_failed($task);
-        // Should not get any task.
+    /**
+     * Test adhoc task failure retry backoff.
+     */
+    public function test_get_next_adhoc_task_fail_retry() {
+        $this->resetAfterTest(true);
+
+        // Create an adhoc task.
+        $task = new \core\task\adhoc_test_task();
+        \core\task\manager::queue_adhoc_task($task);
+
+        $now = time();
+
+        // Get it from the scheduler, execute it, and mark it as failed.
         $task = \core\task\manager::get_next_adhoc_task($now);
-        $this->assertNull($task);
+        $task->execute();
+        \core\task\manager::adhoc_task_failed($task);
+
+        // The task will not be returned immediately.
+        $this->assertNull(\core\task\manager::get_next_adhoc_task($now));
 
         // Should get the adhoc task (retry after delay).
         $task = \core\task\manager::get_next_adhoc_task($now + 120);
-        $this->assertNotNull($task);
+        $this->assertInstanceOf('\\core\\task\\adhoc_test_task', $task);
         $task->execute();
 
         \core\task\manager::adhoc_task_complete($task);
 
         // Should not get any task.
-        $task = \core\task\manager::get_next_adhoc_task($now);
-        $this->assertNull($task);
+        $this->assertNull(\core\task\manager::get_next_adhoc_task($now));
+    }
+
+    /**
+     * Test future adhoc task execution.
+     */
+    public function test_get_next_adhoc_task_future() {
+        $this->resetAfterTest(true);
+
+        $now = time();
+        // Create an adhoc task in future.
+        $task = new \core\task\adhoc_test_task();
+        $task->set_next_run_time($now + 1000);
+        \core\task\manager::queue_adhoc_task($task);
+
+        // Fetching the next task should not return anything.
+        $this->assertNull(\core\task\manager::get_next_adhoc_task($now));
+
+        // Fetching in the future should return the task.
+        $task = \core\task\manager::get_next_adhoc_task($now + 1020);
+        $this->assertInstanceOf('\\core\\task\\adhoc_test_task', $task);
+        $task->execute();
+        \core\task\manager::adhoc_task_complete($task);
     }
 }
index 90507b8..e93e98c 100644 (file)
@@ -143,6 +143,20 @@ class behat_forms extends behat_base {
 
     }
 
+    /**
+     * Sets the field to wwwroot plus the given path. Include the first slash.
+     *
+     * @Given /^I set the field "(?P<field_string>(?:[^"]|\\")*)" to local url "(?P<field_path_string>(?:[^"]|\\")*)"$/
+     * @throws ElementNotFoundException Thrown by behat_base::find
+     * @param string $field
+     * @param string $path
+     * @return void
+     */
+    public function i_set_the_field_to_local_url($field, $path) {
+        global $CFG;
+        $this->set_field_value($field, $CFG->wwwroot . $path);
+    }
+
     /**
      * Sets the specified value to the field.
      *
index 1e979a8..46ce3ef 100644 (file)
@@ -1266,7 +1266,7 @@ class behat_general extends behat_base {
      * @param string $link the text of the link.
      * @return string the content of the downloaded file.
      */
-    protected function download_file_from_link($link) {
+    public function download_file_from_link($link) {
         // Find the link.
         $linknode = $this->find_link($link);
         $this->ensure_node_is_visible($linknode);
index f537cbe..c2a9862 100644 (file)
@@ -504,19 +504,6 @@ class behat_hooks extends behat_base {
         $this->look_for_exceptions();
     }
 
-    /**
-     * Converts HTML tags to line breaks to display the info in CLI
-     *
-     * @param string $html
-     * @return string
-     */
-    protected function get_debug_text($html) {
-
-        // Replacing HTML tags for new lines and keeping only the text.
-        $notags = preg_replace('/<+\s*\/*\s*([A-Z][A-Z0-9]*)\b[^>]*\/*\s*>*/i', "\n", $html);
-        return preg_replace("/(\n)+/s", "\n", $notags);
-    }
-
     /**
      * Returns whether the first scenario of the suite is running
      *
index b3b0198..c018617 100644 (file)
@@ -96,6 +96,40 @@ class core_externallib_testcase extends advanced_testcase {
 </span></span>', FORMAT_HTML);
         $this->assertSame(external_format_text($test, $testformat, $context->id, 'core', '', 0), $correct);
 
+        $test = '<p><a id="test"></a><a href="#test">Text</a></p>';
+        $testformat = FORMAT_HTML;
+        $correct = array($test, FORMAT_HTML);
+        $options = array('allowid' => true);
+        $this->assertSame(external_format_text($test, $testformat, $context->id, 'core', '', 0, $options), $correct);
+
+        $test = '<p><a id="test"></a><a href="#test">Text</a></p>';
+        $testformat = FORMAT_HTML;
+        $correct = array('<p><a></a><a href="#test">Text</a></p>', FORMAT_HTML);
+        $options = new StdClass();
+        $options->allowid = false;
+        $this->assertSame(external_format_text($test, $testformat, $context->id, 'core', '', 0, $options), $correct);
+
+        $test = '<p><a id="test"></a><a href="#test">Text</a></p>'."\n".'Newline';
+        $testformat = FORMAT_MOODLE;
+        $correct = array('<p><a id="test"></a><a href="#test">Text</a></p> Newline', FORMAT_HTML);
+        $options = new StdClass();
+        $options->newlines = false;
+        $this->assertSame(external_format_text($test, $testformat, $context->id, 'core', '', 0, $options), $correct);
+
+        $test = '<p><a id="test"></a><a href="#test">Text</a></p>';
+        $testformat = FORMAT_MOODLE;
+        $correct = array('<div class="text_to_html">'.$test.'</div>', FORMAT_HTML);
+        $options = new StdClass();
+        $options->para = true;
+        $this->assertSame(external_format_text($test, $testformat, $context->id, 'core', '', 0, $options), $correct);
+
+        $test = '<p><a id="test"></a><a href="#test">Text</a></p>';
+        $testformat = FORMAT_MOODLE;
+        $correct = array($test, FORMAT_HTML);
+        $options = new StdClass();
+        $options->context = $context;
+        $this->assertSame(external_format_text($test, $testformat, $context->id, 'core', '', 0, $options), $correct);
+
         $settings->set_raw($currentraw);
         $settings->set_filter($currentfilter);
     }
@@ -283,7 +317,7 @@ class core_externallib_testcase extends advanced_testcase {
      * @dataProvider all_external_info_provider
      */
     public function test_all_external_info($f) {
-        $desc = external_function_info($f);
+        $desc = external_api::external_function_info($f);
         $this->assertNotEmpty($desc->name);
         $this->assertNotEmpty($desc->classname);
         $this->assertNotEmpty($desc->methodname);
@@ -354,6 +388,46 @@ class core_externallib_testcase extends advanced_testcase {
         // The extra course passed is not returned.
         $this->assertArrayNotHasKey($c4->id, $courses);
     }
+
+
+    public function test_call_external_function() {
+        global $PAGE, $COURSE;
+
+        $this->resetAfterTest(true);
+
+        // Call some webservice functions and verify they are correctly handling $PAGE and $COURSE.
+        // First test a function that calls validate_context outside a course.
+        $this->setAdminUser();
+        $category = $this->getDataGenerator()->create_category();
+        $params = array(
+            'contextid' => context_coursecat::instance($category->id)->id,
+            'name' => 'aaagrrryyy',
+            'idnumber' => '',
+            'description' => ''
+        );
+        $cohort1 = $this->getDataGenerator()->create_cohort($params);
+        $cohort2 = $this->getDataGenerator()->create_cohort();
+
+        $beforepage = $PAGE;
+        $beforecourse = $COURSE;
+        $params = array('cohortids' => array($cohort1->id, $cohort2->id));
+        $result = external_api::call_external_function('core_cohort_get_cohorts', $params);
+
+        $this->assertSame($beforepage, $PAGE);
+        $this->assertSame($beforecourse, $COURSE);
+
+        // Now test a function that calls validate_context inside a course.
+        $course = $this->getDataGenerator()->create_course();
+
+        $beforepage = $PAGE;
+        $beforecourse = $COURSE;
+        $params = array('courseid' => $course->id, 'options' => array());
+        $result = external_api::call_external_function('core_enrol_get_enrolled_users', $params);
+
+        $this->assertSame($beforepage, $PAGE);
+        $this->assertSame($beforecourse, $COURSE);
+    }
+
 }
 
 /*
diff --git a/lib/tests/fixtures/unoconv-source.docx b/lib/tests/fixtures/unoconv-source.docx
new file mode 100644 (file)
index 0000000..286c58a
Binary files /dev/null and b/lib/tests/fixtures/unoconv-source.docx differ
diff --git a/lib/tests/fixtures/unoconv-source.html b/lib/tests/fixtures/unoconv-source.html
new file mode 100644 (file)
index 0000000..45ad289
--- /dev/null
@@ -0,0 +1,12 @@
+<!DOCTYPE html>
+<html>
+<body>
+
+<h1>My First Heading</h1>
+
+<p>My first paragraph.</p>
+
+</body>
+</html>
+
+
diff --git a/lib/tests/unoconv_test.php b/lib/tests/unoconv_test.php
new file mode 100644 (file)
index 0000000..3038abb
--- /dev/null
@@ -0,0 +1,108 @@
+<?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/>.
+
+/**
+ * Test unoconv functionality.
+ *
+ * @package    core
+ * @copyright  2016 Damyon Wiese
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+
+/**
+ * A set of tests for some of the unoconv functionality within Moodle.
+ *
+ * @package    core
+ * @copyright  2016 Damyon Wiese
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class core_unoconv_testcase extends advanced_testcase {
+
+    /** @var $testfile1 */
+    private $testfile1 = null;
+    /** @var $testfile2 */
+    private $testfile2 = null;
+
+    public function setUp() {
+        $this->fixturepath = __DIR__ . DIRECTORY_SEPARATOR . 'fixtures' . DIRECTORY_SEPARATOR;
+
+        $fs = get_file_storage();
+        $filerecord = array(
+            'contextid' => context_system::instance()->id,
+            'component' => 'test',
+            'filearea' => 'unittest',
+            'itemid' => 0,
+            'filepath' => '/',
+            'filename' => 'test.html'
+        );
+        $teststring = file_get_contents($this->fixturepath . DIRECTORY_SEPARATOR . 'unoconv-source.html');
+        $this->testfile1 = $fs->create_file_from_string($filerecord, $teststring);
+
+        $filerecord = array(
+            'contextid' => context_system::instance()->id,
+            'component' => 'test',
+            'filearea' => 'unittest',
+            'itemid' => 0,
+            'filepath' => '/',
+            'filename' => 'test.docx'
+        );
+        $teststring = file_get_contents($this->fixturepath . DIRECTORY_SEPARATOR . 'unoconv-source.docx');
+        $this->testfile2 = $fs->create_file_from_string($filerecord, $teststring);
+
+        $this->resetAfterTest();
+    }
+
+    public function test_generate_pdf() {
+        global $CFG;
+
+        if (empty($CFG->pathtounoconv) || !is_executable(trim($CFG->pathtounoconv))) {
+            // No conversions are possible, sorry.
+            return $this->markTestSkipped();
+        }
+        $fs = get_file_storage();
+
+        $result = $fs->get_converted_document($this->testfile1, 'pdf');
+        $this->assertNotFalse($result);
+        $this->assertSame($result->get_mimetype(), 'application/pdf');
+        $this->assertGreaterThan(0, $result->get_filesize());
+        $result = $fs->get_converted_document($this->testfile2, 'pdf');
+        $this->assertNotFalse($result);
+        $this->assertSame($result->get_mimetype(), 'application/pdf');
+        $this->assertGreaterThan(0, $result->get_filesize());
+    }
+
+    public function test_generate_markdown() {
+        global $CFG;
+
+        if (empty($CFG->pathtounoconv) || !is_executable(trim($CFG->pathtounoconv))) {
+            // No conversions are possible, sorry.
+            return $this->markTestSkipped();
+        }
+        $fs = get_file_storage();
+
+        $result = $fs->get_converted_document($this->testfile1, 'txt');
+        $this->assertNotFalse($result);
+        $this->assertSame($result->get_mimetype(), 'text/plain');
+        $this->assertGreaterThan(0, $result->get_filesize());
+        $result = $fs->get_converted_document($this->testfile2, 'txt');
+        $this->assertNotFalse($result);
+        $this->assertSame($result->get_mimetype(), 'text/plain');
+        $this->assertGreaterThan(0, $result->get_filesize());
+    }
+}
index cb4b8aa..172d9c2 100644 (file)
@@ -3,6 +3,11 @@ information provided here is intended especially for developers.
 
 === 3.1 ===
 
+* Webservice function core_course_search_courses accepts a new parameter 'limittoenrolled' to filter the results
+  only to courses the user is enrolled in, and are visible to them.
+* External functions that are not calling external_api::validate_context are buggy and will now generate
+  exceptions. Previously they were only generating warnings in the webserver error log.
+  See https://docs.moodle.org/dev/External_functions_API#Security
 * The moodle/blog:associatecourse and moodle/blog:associatemodule capabilities has been removed.
 * The following functions has been finally deprecated and can not be used any more:
     - profile_display_badges()
@@ -56,7 +61,10 @@ information provided here is intended especially for developers.
     - upgrade_course_modules_sequences()
     - upgrade_grade_item_fix_sortorder()
     - upgrade_availability_item()
-
+* A new parameter $ajaxformdata was added to the constructor for moodleform. When building a
+  moodleform in a webservice or ajax script (for example using the new fragments API) we
+  cannot allow the moodleform to parse it's own data from _GET and _POST - we must pass it as
+  an array.
 * Plugins can extend the navigation for user by declaring the following callback:
   <frankenstyle>_extend_navigation_user(navigation_node $parentnode, stdClass $user,
                                         context_user $context, stdClass $course,
diff --git a/mod/assign/amd/build/grading_actions.min.js b/mod/assign/amd/build/grading_actions.min.js
new file mode 100644 (file)
index 0000000..a68cc23
Binary files /dev/null and b/mod/assign/amd/build/grading_actions.min.js differ
diff --git a/mod/assign/amd/build/grading_form_change_checker.min.js b/mod/assign/amd/build/grading_form_change_checker.min.js
new file mode 100644 (file)
index 0000000..e9828dc
Binary files /dev/null and b/mod/assign/amd/build/grading_form_change_checker.min.js differ
diff --git a/mod/assign/amd/build/grading_navigation.min.js b/mod/assign/amd/build/grading_navigation.min.js
new file mode 100644 (file)
index 0000000..f14a5af
Binary files /dev/null and b/mod/assign/amd/build/grading_navigation.min.js differ
diff --git a/mod/assign/amd/build/grading_navigation_user_info.min.js b/mod/assign/amd/build/grading_navigation_user_info.min.js
new file mode 100644 (file)
index 0000000..5b5804a
Binary files /dev/null and b/mod/assign/amd/build/grading_navigation_user_info.min.js differ
diff --git a/mod/assign/amd/build/grading_panel.min.js b/mod/assign/amd/build/grading_panel.min.js
new file mode 100644 (file)
index 0000000..83022c4
Binary files /dev/null and b/mod/assign/amd/build/grading_panel.min.js differ
diff --git a/mod/assign/amd/build/grading_review_panel.min.js b/mod/assign/amd/build/grading_review_panel.min.js
new file mode 100644 (file)
index 0000000..084992e
Binary files /dev/null and b/mod/assign/amd/build/grading_review_panel.min.js differ
diff --git a/mod/assign/amd/build/participant_selector.min.js b/mod/assign/amd/build/participant_selector.min.js
new file mode 100644 (file)
index 0000000..f7f393b
Binary files /dev/null and b/mod/assign/amd/build/participant_selector.min.js differ
diff --git a/mod/assign/amd/src/grading_actions.js b/mod/assign/amd/src/grading_actions.js
new file mode 100644 (file)
index 0000000..1373f22
--- /dev/null
@@ -0,0 +1,89 @@
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Javascript controller for the "Actions" panel at the bottom of the page.
+ *
+ * @module     mod_assign/grading_actions
+ * @package    mod_assign
+ * @class      GradingActions
+ * @copyright  2016 Damyon Wiese <damyon@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ * @since      3.1
+ */
+define(['jquery'], function($) {
+
+    /**
+     * GradingActions class.
+     *
+     * @class GradingActions
+     * @param {String} selector The selector for the page region containing the actions panel.
+     */
+    var GradingActions = function(selector) {
+        this._regionSelector = selector;
+        this._region = $(selector);
+
+        $(document).on('user-changed', this._showActionsForm.bind(this));
+
+        this._region.find('[name="savechanges"]').on('click', this._trigger.bind(this, 'save-changes'));
+        this._region.find('[name="resetbutton"]').on('click', this._trigger.bind(this, 'reset'));
+        this._region.find('form').on('submit', function(e) { e.preventDefault(); });
+    };
+
+    /** @type {String} Selector for the page region containing the user navigation. */
+    GradingActions.prototype._regionSelector = null;
+
+    /** @type {Integer} Remember the last user id to prevent unnessecary reloads. */
+    GradingActions.prototype._lastUserId = 0;
+
+    /** @type {JQuery} JQuery node for the page region containing the user navigation. */
+    GradingActions.prototype._region = null;
+
+    /**
+     * Show the actions if there is valid user.
+     *
+     * @method _showActionsForm
+     * @private
+     * @param {Event} event
+     * @param {Integer} userid
+     * @return {Deferred} promise resolved when the animations are complete.
+     */
+    GradingActions.prototype._showActionsForm = function(event, userid) {
+        var form = this._region.find('[data-region=grading-actions-form]');
+
+        if (userid != this._lastUserId && userid > 0) {
+            this._lastUserId = userid;
+        }
+        if (userid > 0) {
+            form.removeClass('hide');
+        } else {
+            form.addClass('hide');
+        }
+
+    };
+
+    /**
+     * Trigger the named action.
+     *
+     * @method _trigger
+     * @private
+     * @param {String} action
+     */
+    GradingActions.prototype._trigger = function(action) {
+        $(document).trigger(action);
+    };
+
+    return GradingActions;
+});
diff --git a/mod/assign/amd/src/grading_form_change_checker.js b/mod/assign/amd/src/grading_form_change_checker.js
new file mode 100644 (file)
index 0000000..0ef2e95
--- /dev/null
@@ -0,0 +1,60 @@
+// 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/>.
+
+/**
+ * Simple method to check for changes to a form between two points in time.
+ *
+ * @module     mod_assign/grading_form_change_checker
+ * @package    mod_assign
+ * @copyright  2016 Damyon Wiese <damyon@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ * @since      3.1
+ */
+define(['jquery'], function($) {
+
+    return /** @alias module:mod_assign/grading_form_change_checker */ {
+        /**
+         * Save the values in the form to a data attribute so they can be compared later for changes.
+         *
+         * @method saveFormState
+         * @param {String} selector The selector for the form element.
+         */
+        saveFormState: function(selector) {
+            $(selector).trigger('save-form-state');
+            var data = $(selector).serialize();
+            $(selector).data('saved-form-state', data);
+        },
+
+        /**
+         * Compare the current values in the form to the previously saved state.
+         *
+         * @method checkFormForChanges
+         * @param {String} selector The selector for the form element.
+         * @return {Boolean} True if there are changes to the form data.
+         */
+        checkFormForChanges: function(selector) {
+
+            $(selector).trigger('save-form-state');
+
+            var data = $(selector).serialize(),
+                previousdata = $(selector).data('saved-form-state');
+
+            if (typeof previousdata === 'undefined') {
+                return false;
+            }
+            return (previousdata != data);
+        }
+    };
+});
diff --git a/mod/assign/amd/src/grading_navigation.js b/mod/assign/amd/src/grading_navigation.js
new file mode 100644 (file)
index 0000000..9f50c7f
--- /dev/null
@@ -0,0 +1,470 @@
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Javascript to handle changing users via the user selector in the header.
+ *
+ * @module     mod_assign/grading_navigation
+ * @package    mod_assign
+ * @copyright  2016 Damyon Wiese <damyon@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ * @since      3.1
+ */
+define(['jquery', 'core/notification', 'core/str', 'core/form-autocomplete',
+        'core/ajax', 'mod_assign/grading_form_change_checker'],
+       function($, notification, str, autocomplete, ajax, checker) {
+
+    /**
+     * GradingNavigation class.
+     *
+     * @class GradingNavigation
+     * @param {String} selector The selector for the page region containing the user navigation.
+     */
+    var GradingNavigation = function(selector) {
+        this._regionSelector = selector;
+        this._region = $(selector);
+        this._filters = [];
+        this._users = [];
+        this._filteredUsers = [];
+
+        // Get the current user list from a webservice.
+        this._loadAllUsers();
+
+        // Attach listeners to the select and arrow buttons.
+
+        this._region.find('[data-action="previous-user"]').on('click', this._handlePreviousUser.bind(this));
+        this._region.find('[data-action="next-user"]').on('click', this._handleNextUser.bind(this));
+        this._region.find('[data-action="change-user"]').on('change', this._handleChangeUser.bind(this));
+        this._region.find('[data-region="user-filters"]').on('click', this._toggleExpandFilters.bind(this));
+
+        $(document).on('user-changed', this._refreshSelector.bind(this));
+
+        // Position the configure filters panel under the link that expands it.
+        var toggleLink = this._region.find('[data-region="user-filters"]');
+        var configPanel = $(document.getElementById(toggleLink.attr('aria-controls')));
+
+        configPanel.on('change', '[type="checkbox"]', this._filterChanged.bind(this));
+
+        var userid = $('[data-region="grading-navigation-panel"]').data('first-userid');
+        if (userid) {
+            this._selectUserById(userid);
+        }
+
+        str.get_string('changeuser', 'mod_assign').done(function(s) {
+                autocomplete.enhance('[data-action=change-user]', false, 'mod_assign/participant_selector', s);
+            }.bind(this)
+        ).fail(notification.exception);
+
+        // We do not allow navigation while ajax requests are pending.
+
+        $(document).bind("start-loading-user", function(){
+            this._isLoading = true;
+        }.bind(this));
+        $(document).bind("finish-loading-user", function(){
+            this._isLoading = false;
+        }.bind(this));
+    };
+
+    /** @type {Boolean} Boolean tracking active ajax requests. */
+    GradingNavigation.prototype._isLoading = false;
+
+    /** @type {String} Selector for the page region containing the user navigation. */
+    GradingNavigation.prototype._regionSelector = null;
+
+    /** @type {Array} The list of active filter keys */
+    GradingNavigation.prototype._filters = null;
+
+    /** @type {Array} The list of users */
+    GradingNavigation.prototype._users = null;
+
+    /** @type {JQuery} JQuery node for the page region containing the user navigation. */
+    GradingNavigation.prototype._region = null;
+
+    /**
+     * Load the list of all users for this assignment.
+     *
+     * @private
+     * @method _loadAllUsers
+     */
+    GradingNavigation.prototype._loadAllUsers = function() {
+        var select = this._region.find('[data-action=change-user]');
+        var assignmentid = select.attr('data-assignmentid');
+        var groupid = select.attr('data-groupid');
+
+        ajax.call([{
+            methodname: 'mod_assign_list_participants',
+            args: { assignid: assignmentid, groupid: groupid, filter: '', onlyids: true },
+            done: this._usersLoaded.bind(this),
+            fail: notification.exception
+        }]);
+    };
+
+    /**
+     * Call back to rebuild the user selector and x of y info when the user list is updated.
+     *
+     * @private
+     * @method _usersLoaded
+     * @param {Array} users
+     */
+    GradingNavigation.prototype._usersLoaded = function(users) {
+        this._filteredUsers = this._users = users;
+        if (this._users.length) {
+            // Position the configure filters panel under the link that expands it.
+            var toggleLink = this._region.find('[data-region="user-filters"]');
+            var configPanel = $(document.getElementById(toggleLink.attr('aria-controls')));
+
+            configPanel.find('[type="checkbox"]').trigger('change');
+        } else {
+            this._selectNoUser();
+        }
+    };
+
+    /**
+     * Close the configure filters panel if a click is detected outside of it.
+     *
+     * @private
+     * @method _checkClickOutsideConfigureFilters
+     * @param {Event}
+     */
+    GradingNavigation.prototype._checkClickOutsideConfigureFilters = function(event) {
+        var configPanel = this._region.find('[data-region="configure-filters"]');
+
+        if (!configPanel.is(event.target) && configPanel.has(event.target).length === 0) {
+            var toggleLink = this._region.find('[data-region="user-filters"]');
+
+            configPanel.hide();
+            configPanel.attr('aria-hidden', 'true');
+            toggleLink.attr('aria-expanded', 'false');
+            $(document).unbind('click.mod_assign_grading_navigation');
+        }
+    };
+
+    /**
+     * Turn a filter on or off.
+     *
+     * @private
+     * @method _filterChanged
+     * @param {Event}
+     */
+    GradingNavigation.prototype._filterChanged = function(event) {
+        var name = $(event.target).attr('name');
+        var key = name.split('_').pop();
+        var enabled = $(event.target).prop('checked');
+
+        if (enabled) {
+            if (this._filters.indexOf(key) == -1) {
+                this._filters[this._filters.length] = key;
+            }
+        } else {
+            var index = this._filters.indexOf(key);
+            if (index != -1) {
+                this._filters.splice(index, 1);
+            }
+        }
+
+        // Update the active filter string.
+        var filterlist = [];
+        this._region.find('[data-region="configure-filters"]').find('[type="checkbox"]').each(function(idx, ele) {
+            if ($(ele).prop('checked')) {
+                filterlist[filterlist.length] = $(ele).closest('label').text();
+            }
+        }.bind(this));
+        if (filterlist.length) {
+            this._region.find('[data-region="user-filters"] span').text(filterlist.join(', '));
+        } else {
+            str.get_string('nofilters', 'mod_assign').done(function(s) {
+                this._region.find('[data-region="user-filters"] span').text(s);
+            }.bind(this)).fail(notification.exception);
+        }
+
+        // Filter the options in the select box that do not match the current filters.
+
+        var select = this._region.find('[data-action=change-user]');
+        var userid = select.attr('data-selected');
+        var foundIndex = 0;
+
+        this._filteredUsers = [];
+
+        $.each(this._users, function(index, user) {
+            var show = true;
+            $.each(this._filters, function(filterindex, filter) {
+                if (filter == "submitted") {
+                    if (user.submitted == "0") {
+                        show = false;
+                    }
+                } else if (filter == "notsubmitted") {
+                    if (user.submitted == "1") {
+                        show = false;
+                    }
+                } else if (filter == "requiregrading") {
+                    if (user.requiregrading == "0") {
+                        show = false;
+                    }
+                }
+            }.bind(this));
+
+            if (show) {
+                this._filteredUsers[this._filteredUsers.length] = user;
+                if (userid == user.id) {
+                    foundIndex = index;
+                }
+            }
+        }.bind(this));
+
+        if (this._filteredUsers.length) {
+            this._selectUserById(this._filteredUsers[foundIndex].id);
+        } else {
+            this._selectNoUser();
+        }
+    };
+
+    /**
+     * Select no users, because no users match the filters.
+     *
+     * @private
+     * @method _selectNoUser
+     */
+    GradingNavigation.prototype._selectNoUser = function() {
+        // Detect unsaved changes, and offer to save them - otherwise change user right now.
+        if (this._isLoading) {
+            return;
+        }
+        if (checker.checkFormForChanges('[data-region="grade-panel"] .gradeform')) {
+            // Form has changes, so we need to confirm before switching users.
+            str.get_strings([
+                { key: 'unsavedchanges', component: 'mod_assign' },
+                { key: 'unsavedchangesquestion', component: 'mod_assign' },
+                { key: 'saveandcontinue', component: 'mod_assign' },
+                { key: 'cancel', component: 'core' },
+            ]).done(function(strs) {
+                notification.confirm(strs[0], strs[1], strs[2], strs[3], function() {
+                    $(document).trigger('save-changes', -1);
+                });
+            }.bind(this));
+        } else {
+            $(document).trigger('user-changed', -1);
+        }
+    };
+
+    /**
+     * Select the specified user by id.
+     *
+     * @private
+     * @method _selectUserById
+     * @param {Number} userid
+     */
+    GradingNavigation.prototype._selectUserById = function(userid) {
+        var select = this._region.find('[data-action=change-user]');
+        var useridnumber = parseInt(userid, 10);
+
+        // Detect unsaved changes, and offer to save them - otherwise change user right now.
+        if (this._isLoading) {
+            return;
+        }
+        if (checker.checkFormForChanges('[data-region="grade-panel"] .gradeform')) {
+            // Form has changes, so we need to confirm before switching users.
+            str.get_strings([
+                { key: 'unsavedchanges', component: 'mod_assign' },
+                { key: 'unsavedchangesquestion', component: 'mod_assign' },
+                { key: 'saveandcontinue', component: 'mod_assign' },
+                { key: 'cancel', component: 'core' },
+            ]).done(function(strs) {
+                notification.confirm(strs[0], strs[1], strs[2], strs[3], function() {
+                    $(document).trigger('save-changes', useridnumber);
+                });
+            }.bind(this));
+        } else {
+            select.attr('data-selected', userid);
+
+            if (!isNaN(useridnumber) && useridnumber > 0) {
+                $(document).trigger('user-changed', userid);
+            }
+        }
+    };
+
+    /**
+     * Expand or collapse the filter config panel.
+     *
+     * @private
+     * @method _toggleExpandFilters
+     * @param {Event}
+     */
+    GradingNavigation.prototype._toggleExpandFilters = function(event) {
+        event.preventDefault();
+        var toggleLink = $(event.target).closest('[data-region="user-filters"]');
+        var expanded = toggleLink.attr('aria-expanded') == 'true';
+        var configPanel = $(document.getElementById(toggleLink.attr('aria-controls')));
+
+        if (expanded) {
+            configPanel.hide();
+            configPanel.attr('aria-hidden', 'true');
+            toggleLink.attr('aria-expanded', 'false');
+            $(document).unbind('click.mod_assign_grading_navigation');
+        } else {
+            configPanel.css('display', 'inline-block');
+            configPanel.attr('aria-hidden', 'false');
+            toggleLink.attr('aria-expanded', 'true');
+            event.stopPropagation();
+            $(document).on('click.mod_assign_grading_navigation', this._checkClickOutsideConfigureFilters.bind(this));
+        }
+    };
+
+    /**
+     * Change to the previous user in the grading list.
+     *
+     * @private
+     * @method _handlePreviousUser
+     * @param {Event} e
+     */
+    GradingNavigation.prototype._handlePreviousUser = function(e) {
+        e.preventDefault();
+        var select = this._region.find('[data-action=change-user]');
+        var currentUserId = select.attr('data-selected');
+        var i = 0, currentIndex = 0;
+
+        for (i = 0; i < this._filteredUsers.length; i++) {
+            if (this._filteredUsers[i].id == currentUserId) {
+                currentIndex = i;
+                break;
+            }
+        }
+
+        var count = this._filteredUsers.length;
+        var newIndex = (currentIndex - 1);
+        if (newIndex < 0) {
+            newIndex = count - 1;
+        }
+
+        if (count) {
+            this._selectUserById(this._filteredUsers[newIndex].id);
+        }
+    };
+
+    /**
+     * Change to the next user in the grading list.
+     *
+     * @param {Event} e
+     */
+    GradingNavigation.prototype._handleNextUser = function(e) {
+        e.preventDefault();
+        var select = this._region.find('[data-action=change-user]');
+        var currentUserId = select.attr('data-selected');
+        var i = 0, currentIndex = 0;
+
+        for (i = 0; i < this._filteredUsers.length; i++) {
+            if (this._filteredUsers[i].id == currentUserId) {
+                currentIndex = i;
+                break;
+            }
+        }
+
+        var count = this._filteredUsers.length;
+        var newIndex = (currentIndex + 1) % count;
+
+        if (count) {
+            this._selectUserById(this._filteredUsers[newIndex].id);
+        }
+    };
+
+    /**
+     * Rebuild the x of y string.
+     *
+     * @private
+     * @method _refreshCount
+     */
+    GradingNavigation.prototype._refreshCount = function() {
+        var select = this._region.find('[data-action=change-user]');
+        var userid = select.attr('data-selected');
+        var i = 0;
+        var currentIndex = 0;
+
+        if (isNaN(userid) || userid <= 0) {
+            this._region.find('[data-region="user-count"]').hide();
+        } else {
+            this._region.find('[data-region="user-count"]').show();
+
+            for (i = 0; i < this._filteredUsers.length; i++) {
+                if (this._filteredUsers[i].id == userid) {
+                    currentIndex = i;
+                    break;
+                }
+            }
+            var count = this._filteredUsers.length;
+            if (count) {
+                currentIndex += 1;
+            }
+            var param = { x: currentIndex, y: count };
+
+            str.get_string('xofy', 'mod_assign', param).done(function(s) {
+                this._region.find('[data-region="user-count-summary"]').text(s);
+            }.bind(this)).fail(notification.exception);
+        }
+    };
+
+    /**
+     * Respond to a user-changed event by updating the selector.
+     *
+     * @private
+     * @method _refreshSelector
+     * @param {Event} event
+     * @param {String} userid
+     */
+    GradingNavigation.prototype._refreshSelector = function(event, userid) {
+        var select = this._region.find('[data-action=change-user]');
+        userid = parseInt(userid, 10);
+
+        if (!isNaN(userid) && userid > 0) {
+            select.attr('data-selected', userid);
+        }
+        this._refreshCount();
+    };
+
+    /**
+     * Change to a different user in the grading list.
+     *
+     * @private
+     * @method _handleChangeUser
+     * @param {Event}
+     */
+    GradingNavigation.prototype._handleChangeUser = function() {
+        var select = this._region.find('[data-action=change-user]');
+        var userid = parseInt(select.val(), 10);
+
+        if (this._isLoading) {
+            return;
+        }
+        if (checker.checkFormForChanges('[data-region="grade-panel"] .gradeform')) {
+            // Form has changes, so we need to confirm before switching users.
+            str.get_strings([
+                { key: 'unsavedchanges', component: 'mod_assign' },
+                { key: 'unsavedchangesquestion', component: 'mod_assign' },
+                { key: 'saveandcontinue', component: 'mod_assign' },
+                { key: 'cancel', component: 'core' },
+            ]).done(function(strs) {
+                notification.confirm(strs[0], strs[1], strs[2], strs[3], function() {
+                    $(document).trigger('save-changes', userid);
+                });
+            }.bind(this));
+        } else {
+            if (!isNaN(userid) && userid > 0) {
+                select.attr('data-selected', userid);
+
+                $(document).trigger('user-changed', userid);
+            }
+        }
+    };
+
+    return GradingNavigation;
+});
diff --git a/mod/assign/amd/src/grading_navigation_user_info.js b/mod/assign/amd/src/grading_navigation_user_info.js
new file mode 100644 (file)
index 0000000..617af68
--- /dev/null
@@ -0,0 +1,147 @@
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Javascript controller for the "User summary" panel at the top of the page.
+ *
+ * @module     mod_assign/grading_navigation_user_info
+ * @package    mod_assign
+ * @class      UserInfo
+ * @copyright  2016 Damyon Wiese <damyon@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ * @since      3.1
+ */
+define(['jquery', 'core/notification', 'core/ajax', 'core/templates'], function($, notification, ajax, templates) {
+
+    /**
+     * UserInfo class.
+     *
+     * @class UserInfo
+     * @param {String} selector The selector for the page region containing the user navigation.
+     */
+    var UserInfo = function(selector) {
+        this._regionSelector = selector;
+        this._region = $(selector);
+        this._userCache = [];
+
+        $(document).on('user-changed', this._refreshUserInfo.bind(this));
+    };
+
+    /** @type {String} Selector for the page region containing the user navigation. */
+    UserInfo.prototype._regionSelector = null;
+
+    /** @type {Array} Cache of user info contexts. */
+    UserInfo.prototype._userCache = null;
+
+    /** @type {JQuery} JQuery node for the page region containing the user navigation. */
+    UserInfo.prototype._region = null;
+
+    /** @type {Integer} Remember the last user id to prevent unnessecary reloads. */
+    UserInfo.prototype._lastUserId = 0;
+
+    /**
+     * Get the user context - re-render the template in the page.
+     *
+     * @private
+     * @method _refreshUserInfo
+     * @param {Event} event
+     * @param {Number} userid
+     */
+    UserInfo.prototype._refreshUserInfo = function(event, userid) {
+        var promise = $.Deferred();
+
+        // Skip reloading if it is the same user.
+        if (this._lastUserId == userid) {
+            return;
+        }
+        this._lastUserId = userid;
+
+        // First insert the loading template.
+        templates.render('mod_assign/loading', {}).done(function(html, js) {
+            // Update the page.
+            this._region.fadeOut("fast", function() {
+                templates.replaceNodeContents(this._region, html, js);
+                this._region.fadeIn("fast");
+            }.bind(this));
+
+            if (userid < 0) {
+                // Render the template.
+                templates.render('mod_assign/grading_navigation_no_users', {}).done(function(html, js) {
+                    // Update the page.
+                    this._region.fadeOut("fast", function() {
+                        templates.replaceNodeContents(this._region, html, js);
+                        this._region.fadeIn("fast");
+                    }.bind(this));
+                }.bind(this)).fail(notification.exception);
+                return;
+            }
+
+            if (typeof this._userCache[userid] !== "undefined") {
+                promise.resolve(this._userCache[userid]);
+            } else {
+                // Load context from ajax.
+                var requests = ajax.call([{
+                    methodname: 'core_user_get_users_by_field',
+                    args: { field: 'id', values: [ userid ] }
+                }]);
+
+                requests[0].done(function(result) {
+                    if (result.length < 1) {
+                        promise.reject('No users');
+                    } else {
+                        $.each(result, function(index, user) {
+                            this._userCache[user.id] = user;
+                        }.bind(this));
+                        promise.resolve(this._userCache[userid]);
+                    }
+                }.bind(this)).fail(notification.exception);
+            }
+
+            promise.done(function(context) {
+                var identityfields = $('[data-showuseridentity]').data('showuseridentity').split(','),
+                    identity = [];
+                // Render the template.
+                context.courseid = $('[data-region="grading-navigation-panel"]').attr('data-courseid');
+                // Build a string for the visible identity fields listed in showuseridentity config setting.
+                $.each(identityfields, function(i, k) {
+                    if (typeof context[k] !== 'undefined' && context[k] !== '') {
+                        context.hasidentity = true;
+                        identity.push(context[k]);
+                    }
+                });
+                context.identity = identity.join(', ');
+
+                templates.render('mod_assign/grading_navigation_user_summary', context).done(function(html, js) {
+                    // Update the page.
+                    this._region.fadeOut("fast", function() {
+                        templates.replaceNodeContents(this._region, html, js);
+                        this._region.fadeIn("fast");
+                    }.bind(this));
+                }.bind(this)).fail(notification.exception);
+            }.bind(this)).fail(function() {
+                // Render the template.
+                templates.render('mod_assign/grading_navigation_no_users', {}).done(function(html, js) {
+                    // Update the page.
+                    this._region.fadeOut("fast", function() {
+                        templates.replaceNodeContents(this._region, html, js);
+                        this._region.fadeIn("fast");
+                    }.bind(this));
+                }.bind(this)).fail(notification.exception);
+            });
+        }.bind(this)).fail(notification.exception);
+    };
+
+    return UserInfo;
+});
diff --git a/mod/assign/amd/src/grading_panel.js b/mod/assign/amd/src/grading_panel.js
new file mode 100644 (file)
index 0000000..ba2f8f3
--- /dev/null
@@ -0,0 +1,307 @@
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Javascript controller for the "Grading" panel at the right of the page.
+ *
+ * @module     mod_assign/grading_panel
+ * @package    mod_assign
+ * @class      GradingPanel
+ * @copyright  2016 Damyon Wiese <damyon@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ * @since      3.1
+ */
+define(['jquery', 'core/notification', 'core/templates', 'core/fragment',
+        'core/ajax', 'core/str', 'mod_assign/grading_form_change_checker'],
+       function($, notification, templates, fragment, ajax, str, checker) {
+
+    /**
+     * GradingPanel class.
+     *
+     * @class GradingPanel
+     * @param {String} selector The selector for the page region containing the user navigation.
+     */
+    var GradingPanel = function(selector) {
+        this._regionSelector = selector;
+        this._region = $(selector);
+        this._userCache = [];
+
+        $(document).on('user-changed', this._refreshGradingPanel.bind(this));
+        $(document).on('save-changes', this._submitForm.bind(this));
+        $(document).on('reset', this._resetForm.bind(this));
+
+        $(document).on('save-form-state', this._saveFormState.bind(this));
+    };
+
+    /** @type {String} Selector for the page region containing the user navigation. */
+    GradingPanel.prototype._regionSelector = null;
+
+    /** @type {Integer} Remember the last user id to prevent unnessecary reloads. */
+    GradingPanel.prototype._lastUserId = 0;
+
+    /** @type {Integer} Remember the last attempt number to prevent unnessecary reloads. */
+    GradingPanel.prototype._lastAttemptNumber = -1;
+
+    /** @type {JQuery} JQuery node for the page region containing the user navigation. */
+    GradingPanel.prototype._region = null;
+
+    /**
+     * Fade the dom node out, update it, and fade it back.
+     *
+     * @private
+     * @method _niceReplaceNodeContents
+     * @param {JQuery} node
+     * @param {String} html
+     * @param {String} js
+     * @return {Deferred} promise resolved when the animations are complete.
+     */
+    GradingPanel.prototype._niceReplaceNodeContents = function(node, html, js) {
+        var promise = $.Deferred();
+
+        node.fadeOut("fast", function() {
+            templates.replaceNodeContents(node, html, js);
+            node.fadeIn("fast", function() {
+                promise.resolve();
+            });
+        });
+
+        return promise.promise();
+    };
+
+    /**
+     * Make sure all form fields have the latest saved state.
+     * @private
+     * @method _saveFormState
+     */
+    GradingPanel.prototype._saveFormState = function() {
+        // Grrrrr! TinyMCE you know what you did.
+        if (typeof window.tinyMCE !== 'undefined') {
+            window.tinyMCE.triggerSave();
+        }
+
+        // Copy data from notify students checkbox which was moved out of the form.
+        var checked = $('[data-region="grading-actions-form"] [name="sendstudentnotifications"]').val();
+        $('.gradeform [name="sendstudentnotifications"]').val(checked);
+    };
+
+    /**
+     * Make form submit via ajax.
+     *
+     * @private
+     * @method _submitForm
+     */
+    GradingPanel.prototype._submitForm = function(event, nextUserId) {
+        // The form was submitted - send it via ajax instead.
+        var form = $(this._region.find('form.gradeform'));
+
+        $('[data-region="overlay"]').show();
+
+        // We call this, so other modules can update the form with the latest state.
+        form.trigger('save-form-state');
+
+        // Now we get all the current values from the form.
+        var data = form.serialize();
+        var assignmentid = this._region.attr('data-assignmentid');
+
+        // Now we can continue...
+        ajax.call([{
+            methodname: 'mod_assign_submit_grading_form',
+            args: {assignmentid: assignmentid, userid: this._lastUserId, jsonformdata: JSON.stringify(data)},
+            done: this._handleFormSubmissionResponse.bind(this, data, nextUserId),
+            fail: notification.exception
+        }]);
+    };
+
+    /**
+     * Handle form submission response.
+     *
+     * @private
+     * @method _handleFormSubmissionResponse
+     * @param {Array} formdata - submitted values
+     * @param {Integer} nextUserId - optional. The id of the user to load after the form is saved.
+     * @param {Array} response List of errors.
+     */
+    GradingPanel.prototype._handleFormSubmissionResponse = function(formdata, nextUserId, response) {
+        if (typeof nextUserId === "undefined") {
+            nextUserId = this._lastUserId;
+        }
+        if (response.length) {
+            // There was an error saving the grade. Re-render the form using the submitted data so we can show
+            // validation errors.
+            $(document).trigger('reset', [this._lastUserId, formdata]);
+        } else {
+            str.get_strings([
+                { key: 'changessaved', component: 'core' },
+                { key: 'gradechangessaveddetail', component: 'mod_assign' },
+            ]).done(function(strs) {
+                notification.alert(strs[0], strs[1]);
+            }).fail(notification.exception);
+            if (nextUserId == this._lastUserId) {
+                $(document).trigger('reset', nextUserId);
+            } else {
+                $(document).trigger('user-changed', nextUserId);
+            }
+        }
+        $('[data-region="overlay"]').hide();
+    };
+
+    /**
+     * Refresh form with default values.
+     *
+     * @private
+     * @method _resetForm
+     * @param {Event} e
+     * @param {Integer} userid
+     * @param {Array} formdata
+     */
+    GradingPanel.prototype._resetForm = function(e, userid, formdata) {
+        // The form was cancelled - refresh with default values.
+        var event = $.Event("custom");
+        if (typeof userid == "undefined") {
+            userid = this._lastUserId;
+        }
+        this._lastUserId = 0;
+        this._refreshGradingPanel(event, userid, formdata);
+    };
+
+    /**
+     * Open a picker to choose an older attempt.
+     *
+     * @private
+     * @method _chooseAttempt
+     */
+    GradingPanel.prototype._chooseAttempt = function(e) {
+        // Show a dialog.
+
+        // The form is in the element pointed to by data-submissions.
+        var link = $(e.target);
+        var submissionsId = link.data('submissions');
+        var submissionsform = $(document.getElementById(submissionsId));
+        var formcopy = submissionsform.clone();
+        var formhtml = formcopy.wrap($('<form/>')).html();
+
+        str.get_strings([
+            { key: 'viewadifferentattempt', component: 'mod_assign' },
+            { key: 'view', component: 'core' },
+            { key: 'cancel', component: 'core' },
+        ]).done(function(strs) {
+            notification.confirm(strs[0], formhtml, strs[1], strs[2], function() {
+                var attemptnumber = $("input:radio[name='select-attemptnumber']:checked").val();
+
+                this._refreshGradingPanel(null, this._lastUserId, '', attemptnumber);
+            }.bind(this));
+        }.bind(this)).fail(notification.exception);
+    };
+
+    /**
+     * Add popout buttons
+     *
+     * @private
+     * @method _addPopoutButtons
+     * @param {JQuery} region The region to add popout buttons to.
+     */
+    GradingPanel.prototype._addPopoutButtons = function(selector) {
+        var region = $(selector);
+
+        templates.render('mod_assign/popout_button', {}).done(function(html) {
+            region.find('.fitem_ffilemanager .fitemtitle').append(html);
+            region.find('.fitem_feditor .fitemtitle').append(html);
+            region.find('.fitem_f .fitemtitle').append(html);
+
+            region.on('click', '[data-region="popout-button"]', this._togglePopout.bind(this));
+        }.bind(this)).fail(notification.exception);
+    };
+
+    /**
+     * Make a div "popout" or "popback".
+     *
+     * @private
+     * @method _togglePopout
+     * @param {Event} event
+     */
+    GradingPanel.prototype._togglePopout = function(event) {
+        event.preventDefault();
+        var container = $(event.target).closest('.fitem');
+        if (container.hasClass('popout')) {
+            $('.popout').removeClass('popout');
+        } else {
+            $('.popout').removeClass('popout');
+            container.addClass('popout');
+            container.addClass('moodle-has-zindex');
+        }
+    };
+
+    /**
+     * Get the user context - re-render the template in the page.
+     *
+     * @private
+     * @method _refreshGradingPanel
+     * @param {Event} event
+     * @param {Number} userid
+     * @param {String} serialised submission data.
+     */
+    GradingPanel.prototype._refreshGradingPanel = function(event, userid, submissiondata, attemptnumber) {
+        var contextid = this._region.attr('data-contextid');
+        if (typeof submissiondata === 'undefined') {
+            submissiondata = '';
+        }
+        if (typeof attemptnumber === 'undefined') {
+            attemptnumber = -1;
+        }
+        // Skip reloading if it is the same user.
+        if (this._lastUserId == userid && this._lastAttemptNumber == attemptnumber && submissiondata === '') {
+            return;
+        }
+        this._lastUserId = userid;
+        this._lastAttemptNumber = attemptnumber;
+        $(document).trigger('start-loading-user');
+        // Tell behat to back off too.
+        window.M.util.js_pending('mod-assign-loading-user');
+        // First insert the loading template.
+        templates.render('mod_assign/loading', {}).done(function(html, js) {
+            // Update the page.
+            this._niceReplaceNodeContents(this._region, html, js).done(function() {
+                if (userid > 0) {
+                    this._region.show();
+                    // Reload the grading form "fragment" for this user.
+                    var params = { userid: userid, attemptnumber: attemptnumber, jsonformdata: JSON.stringify(submissiondata) };
+                    fragment.loadFragment('mod_assign', 'gradingpanel', contextid, params).done(function(html, js) {
+                        this._niceReplaceNodeContents(this._region, html, js)
+                        .done(function() {
+                            checker.saveFormState('[data-region="grade-panel"] .gradeform');
+                            $('[data-region="attempt-chooser"]').on('click', this._chooseAttempt.bind(this));
+                            this._addPopoutButtons('[data-region="grade-panel"] .gradeform');
+                            $(document).trigger('finish-loading-user');
+                            // Tell behat we are friends again.
+                            window.M.util.js_complete('mod-assign-loading-user');
+                        }.bind(this))
+                        .fail(notification.exception);
+                    }.bind(this)).fail(notification.exception);
+                } else {
+                    this._region.hide();
+                    var reviewPanel = $('[data-region="review-panel"]');
+                    if (reviewPanel.length) {
+                        this._niceReplaceNodeContents(reviewPanel, '', '');
+                    }
+                    $(document).trigger('finish-loading-user');
+                    // Tell behat we are friends again.
+                    window.M.util.js_complete('mod-assign-loading-user');
+                }
+            }.bind(this));
+        }.bind(this)).fail(notification.exception);
+    };
+
+    return GradingPanel;
+});
diff --git a/mod/assign/amd/src/grading_review_panel.js b/mod/assign/amd/src/grading_review_panel.js
new file mode 100644 (file)
index 0000000..fffe2e4
--- /dev/null
@@ -0,0 +1,62 @@
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Javascript controller for the "Review" panel at the left of the page.
+ *
+ * @module     mod_assign/grading_review_panel
+ * @package    mod_assign
+ * @class      GradingReviewPanel
+ * @copyright  2016 Damyon Wiese <damyon@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ * @since      3.1
+ */
+define(['jquery'], function($) {
+
+    /**
+     * GradingReviewPanel class.
+     *
+     * @class GradingReviewPanel
+     * @param {String} selector The selector for the page region containing the user navigation.
+     */
+    var GradingReviewPanel = function() {
+        this._region = $('[data-region="review-panel"]');
+    };
+
+    /** @type {JQuery} JQuery node for the page region containing the user navigation. */
+    GradingReviewPanel.prototype._region = null;
+
+    /**
+     * It is first come first served to get ownership of the grading review panel.
+     * There can be only one.
+     *
+     * @public
+     * @method getReviewPanel
+     * @param {String} pluginname - the first plugin to ask for the panel gets it.
+     * @return {DOMNode} or false
+     */
+    GradingReviewPanel.prototype.getReviewPanel = function(pluginname) {
+        var owner = this._region.data('panel-owner');
+        if (typeof owner == "undefined") {
+            this._region.data('review-panel-plugin', pluginname);
+        }
+        if (this._region.data('review-panel-plugin') == pluginname) {
+            return this._region[0];
+        }
+        return false;
+    };
+
+    return GradingReviewPanel;
+});
diff --git a/mod/assign/amd/src/participant_selector.js b/mod/assign/amd/src/participant_selector.js
new file mode 100644 (file)
index 0000000..f084b0a
--- /dev/null
@@ -0,0 +1,113 @@
+// 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/>.
+
+/**
+ * Custom auto-complete adapter to load users from the assignment list_participants webservice.
+ *
+ * @module     mod_assign/participants_selector
+ * @copyright  2015 Damyon Wiese <damyon@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+define(['core/ajax', 'jquery', 'core/templates'], function(ajax, $, templates) {
+
+
+    return /** @alias module:mod_assign/participants_selector */ {
+
+        // Public variables and functions.
+        /**
+         * Process the results returned from transport (convert to value + label)
+         *
+         * @method processResults
+         * @param {String} selector
+         * @param {Array} data
+         * @return {Array}
+         */
+        processResults: function(selector, data) {
+            var results = [], i = 0;
+            for (i = 0; i < data.length; i++) {
+                results[i] = { value: data[i].id, label: data[i].label };
+            }
+            return results;
+        },
+
+        /**
+         * Fetch results based on the current query. This also renders each result from a template before returning them.
+         *
+         * @method transport
+         * @param {String} selector Selector for the original select element
+         * @param {String} query Current search string
+         * @param {Function} success Success handler
+         * @param {Function} failure Failure handler
+         */
+        transport: function(selector, query, success, failure) {
+            var assignmentid = $(selector).attr('data-assignmentid');
+            var filters = $('[data-region="configure-filters"] input[type="checkbox"]');
+            var filterstrings = [];
+
+            filters.each(function(index, element) {
+                filterstrings[$(element).attr('name')] = $(element).prop('checked');
+            });
+
+            var promise = ajax.call([{
+                methodname: 'mod_assign_list_participants', args: { assignid: assignmentid, groupid: 0, filter: query, limit: 30 }
+            }]);
+
+            promise[0].then(function(results) {
+                var promises = [];
+                var identityfields = $('[data-showuseridentity]').data('showuseridentity').split(',');
+
+                // We got the results, now we loop over them and render each one from a template.
+                $.each(results, function(index, user) {
+                    var ctx = user,
+                        identity = [],
+                        show = true;
+
+                    if (filterstrings.filter_submitted && !user.submitted) {
+                        show = false;
+                    }
+                    if (filterstrings.filter_notsubmitted && user.submitted) {
+                        show = false;
+                    }
+                    if (filterstrings.filter_requiregrading && !user.requiregrading) {
+                        show = false;
+                    }
+                    if (show) {
+                        $.each(identityfields, function(i, k) {
+                            if (typeof user[k] !== 'undefined' && user[k] !== '') {
+                                ctx.hasidentity = true;
+                                identity.push(user[k]);
+                            }
+                        });
+                        ctx.identity = identity.join(', ');
+                        promises.push(templates.render('mod_assign/list_participant_user_summary', ctx));
+                    }
+                });
+
+                // When all the templates have been rendered, call the success handler.
+                $.when.apply($.when, promises).then(function() {
+                    var args = arguments,
+                        i = 0;
+
+                    $.each(results, function(index, user) {
+                        user.label = args[i];
+                        i++;
+                    });
+
+                    success(results);
+                });
+            }, failure);
+        }
+    };
+});
diff --git a/mod/assign/classes/output/grading_app.php b/mod/assign/classes/output/grading_app.php
new file mode 100644 (file)
index 0000000..df53f22
--- /dev/null
@@ -0,0 +1,163 @@
+<?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/>.
+
+/**
+ * Renderable that initialises the grading "app".
+ *
+ * @package    mod_assign
+ * @copyright  2016 Damyon Wiese
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace mod_assign\output;
+
+defined('MOODLE_INTERNAL') || die();
+
+use renderer_base;
+use renderable;
+use templatable;
+use stdClass;
+
+/**
+ * Grading app renderable.
+ *
+ * @package    mod_assign
+ * @since      Moodle 3.1
+ * @copyright  2016 Damyon Wiese
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class grading_app implements templatable, renderable {
+
+    /**
+     * @var $userid - The initial user id.
+     */
+    public $userid = 0;
+
+    /**
+     * @var $groupid - The initial group id.
+     */
+    public $groupid = 0;
+
+    /**
+     * @var $assignment - The assignment instance.
+     */
+    public $assignment = null;
+
+    /**
+     * Constructor for this renderable.
+     *
+     * @param int $userid The user we will open the grading app too.
+     * @param int $groupid If groups are enabled this is the current course group.
+     * @param assign $assignment The assignment class
+     */
+    public function __construct($userid, $groupid, $assignment) {
+        $this->userid = $userid;
+        $this->groupid = $groupid;
+        $this->assignment = $assignment;
+        $this->participants = $assignment->list_participants_with_filter_status_and_group($groupid);
+        if (!$this->userid && count($this->participants)) {
+            $this->userid = reset($this->participants)->id;
+        }
+    }
+
+    /**
+     * Export this class data as a flat list for rendering in a template.
+     *
+     * @param renderer_base $output The current page renderer.
+     * @return stdClass - Flat list of exported data.
+     */
+    public function export_for_template(renderer_base $output) {
+        global $CFG;
+
+        $export = new stdClass();
+        $export->userid = $this->userid;
+        $export->assignmentid = $this->assignment->get_instance()->id;
+        $export->cmid = $this->assignment->get_course_module()->id;
+        $export->contextid = $this->assignment->get_context()->id;
+        $export->groupid = $this->groupid;
+        $export->name = $this->assignment->get_instance()->name;
+        $export->courseid = $this->assignment->get_course()->id;
+        $export->participants = array();
+        $num = 1;
+        foreach ($this->participants as $idx => $record) {
+            $user = new stdClass();
+            $user->id = $record->id;
+            $user->fullname = fullname($record);
+            $user->requiregrading = $record->requiregrading;
+            $user->submitted = $record->submitted;
+            if (!empty($record->groupid)) {
+                $user->groupid = $record->groupid;
+                $user->groupname = $record->groupname;
+            }
+            if ($record->id == $this->userid) {
+                $export->index = $num;
+                $user->current = true;
+            }
+            $export->participants[] = $user;
+            $num++;
+        }
+
+        $feedbackplugins = $this->assignment->get_feedback_plugins();
+        $showreview = false;
+        foreach ($feedbackplugins as $plugin) {
+            if ($plugin->is_enabled() && $plugin->is_visible()) {
+                if ($plugin->supports_review_panel()) {
+                    $showreview = true;
+                }
+            }
+        }
+
+        $export->showreview = $showreview;
+
+        $time = time();
+        $export->count = count($export->participants);
+        $export->coursename = $this->assignment->get_course_context()->get_context_name();
+        $export->caneditsettings = has_capability('mod/assign:addinstance', $this->assignment->get_context());
+        $export->duedate = $this->assignment->get_instance()->duedate;
+        $export->duedatestr = userdate($this->assignment->get_instance()->duedate);
+
+        // Time remaining.
+        $due = '';
+        if ($export->duedate - $time <= 0) {
+            $due = get_string('assignmentisdue', 'assign');
+        } else {
+            $due = get_string('timeremainingcolon', 'assign', format_time($export->duedate - $time));
+        }
+        $export->timeremainingstr = $due;
+
+        if ($export->duedate < $time) {
+            $export->cutoffdate = $this->assignment->get_instance()->cutoffdate;
+            $cutoffdate = $export->cutoffdate;
+            if ($cutoffdate) {
+                if ($cutoffdate > $time) {
+                    $late = get_string('latesubmissionsaccepted', 'assign', userdate($export->cutoffdate));
+                } else {
+                    $late = get_string('nomoresubmissionsaccepted', 'assign');
+                }
+                $export->cutoffdatestr = $late;
+            }
+        }
+
+        $export->defaultsendnotifications = $this->assignment->get_instance()->sendstudentnotifications;
+        $export->rarrow = $output->rarrow();
+        $export->larrow = $output->larrow();
+        // List of identity fields to display (the user info will not contain any fields the user cannot view anyway).
+        $export->showuseridentity = $CFG->showuseridentity;
+
+        return $export;
+    }
+
+}
index d7dc5fd..8d41225 100644 (file)
@@ -190,4 +190,24 @@ $functions = array(
             'capabilities'  => 'mod/assign:view',
             'services'      => array(MOODLE_OFFICIAL_MOBILE_SERVICE)
         ),
+
+        'mod_assign_list_participants' => array(
+                'classname'     => 'mod_assign_external',
+                'methodname'    => 'list_participants',
+                'classpath'     => 'mod/assign/externallib.php',
+                'description'   => 'List the participants for a single assignment, with some summary info about their submissions.',
+                'type'          => 'read',
+                'ajax'          => true,
+                'capabilities'  => 'mod/assign:view, mod/assign:viewgrades'
+        ),
+
+        'mod_assign_submit_grading_form' => array(
+                'classname'     => 'mod_assign_external',
+                'methodname'    => 'submit_grading_form',
+                'classpath'     => 'mod/assign/externallib.php',
+                'description'   => 'Submit the grading form data via ajax',
+                'type'          => 'write',
+                'ajax'          => true,
+                'capabilities'  => 'mod/assign:grade'
+        ),
 );
index ce6f154..4523113 100644 (file)
@@ -1506,6 +1506,91 @@ class mod_assign_external extends external_api {
         return new external_warnings();
     }
 
+    /**
+     * Describes the parameters for submit_grading_form webservice.
+     * @return external_external_function_parameters
+     * @since  Moodle 3.1
+     */
+    public static function submit_grading_form_parameters() {
+        return new external_function_parameters(
+            array(
+                'assignmentid' => new external_value(PARAM_INT, 'The assignment id to operate on'),
+                'userid' => new external_value(PARAM_INT, 'The user id the submission belongs to'),
+                'jsonformdata' => new external_value(PARAM_RAW, 'The data from the grading form, encoded as a json array')
+            )
+        );
+    }
+
+    /**
+     * Submit the logged in users assignment for grading.
+     *
+     * @param int $assignmentid The id of the assignment
+     * @param int $userid The id of the user the submission belongs to.
+     * @param string $jsonformdata The data from the form, encoded as a json array.
+     * @return array of warnings to indicate any errors.
+     * @since Moodle 2.6
+     */
+    public static function submit_grading_form($assignmentid, $userid, $jsonformdata) {
+        global $CFG, $USER;
+
+        require_once($CFG->dirroot . '/mod/assign/locallib.php');
+        require_once($CFG->dirroot . '/mod/assign/gradeform.php');
+
+        $params = self::validate_parameters(self::submit_grading_form_parameters(),
+                                            array(
+                                                'assignmentid' => $assignmentid,
+                                                'userid' => $userid,
+                                                'jsonformdata' => $jsonformdata
+                                            ));
+
+        $cm = get_coursemodule_from_instance('assign', $params['assignmentid'], 0, false, MUST_EXIST);
+        $context = context_module::instance($cm->id);
+        self::validate_context($context);
+
+        $assignment = new assign($context, $cm, null);
+
+        $serialiseddata = json_decode($params['jsonformdata']);
+
+        $data = array();
+        parse_str($serialiseddata, $data);
+
+        $warnings = array();
+
+        $options = array(
+            'userid' => $params['userid'],
+            'attemptnumber' => $data['attemptnumber'],
+            'rownum' => 0,
+            'gradingpanel' => true
+        );
+
+        $customdata = (object) $data;
+        $formparams = array($assignment, $customdata, $options);
+
+        // Data is injected into the form by the last param for the constructor.
+        $mform = new mod_assign_grade_form(null, $formparams, 'post', '', null, true, $data);
+        $validateddata = $mform->get_data();
+
+        if ($validateddata) {
+            $assignment->save_grade($params['userid'], $validateddata);
+        } else {
+            $warnings[] = self::generate_warning($params['assignmentid'],
+                                                 'couldnotsavegrade',
+                                                 'Form validation failed.');
+        }
+
+
+        return $warnings;
+    }
+
+    /**
+     * Describes the return for submit_grading_form
+     * @return external_external_function_parameters
+     * @since  Moodle 3.1
+     */
+    public static function submit_grading_form_returns() {
+        return new external_warnings();
+    }
+
     /**
      * Describes the parameters for submit_for_grading
      * @return external_external_function_parameters
@@ -2185,6 +2270,7 @@ class mod_assign_external extends external_api {
             )
         );
     }
+
     /**
      * Describes the parameters for view_submission_status.
      *
@@ -2486,4 +2572,194 @@ class mod_assign_external extends external_api {
         );
     }
 
+    /**
+     * Returns description of method parameters
+     *
+     * @return external_function_parameters
+     * @since Moodle 3.1
+     */
+    public static function list_participants_parameters() {
+        return new external_function_parameters(