Merge branch 'MDL-59702' of https://github.com/andrewhancox/moodle
authorAndrew Nicols <andrew@nicols.co.uk>
Tue, 22 Aug 2017 03:01:30 +0000 (11:01 +0800)
committerAndrew Nicols <andrew@nicols.co.uk>
Tue, 22 Aug 2017 03:01:30 +0000 (11:01 +0800)
391 files changed:
admin/cli/mysql_collation.php
admin/registration/forms.php
admin/registration/index.php
admin/registration/register.php
admin/renderer.php
admin/search.php
admin/settings/analytics.php
admin/settings/top.php
admin/tests/behat/manage_tokens.feature [new file with mode: 0644]
admin/tool/analytics/classes/output/models_list.php
admin/tool/analytics/lang/en/tool_analytics.php
admin/tool/analytics/model.php
admin/tool/behat/renderer.php
admin/tool/behat/tests/behat/data_generators.feature
admin/tool/monitor/tests/behat/rule.feature
admin/tool/recyclebin/tests/events_test.php
admin/webservice/tokens.php
analytics/classes/dataset_manager.php
analytics/classes/local/analyser/base.php
analytics/classes/local/analyser/by_course.php
analytics/classes/local/analyser/sitewide.php
analytics/classes/local/time_splitting/accumulative_parts.php
analytics/classes/local/time_splitting/equal_parts.php
analytics/classes/manager.php
analytics/classes/model.php
analytics/classes/site.php
analytics/tests/prediction_test.php
auth/ldap/auth.php
auth/ldap/classes/task/sync_roles.php [new file with mode: 0644]
auth/ldap/db/tasks.php
auth/ldap/db/upgrade.php
auth/ldap/lang/en/auth_ldap.php
auth/ldap/lang/en/deprecated.txt [new file with mode: 0644]
auth/ldap/locallib.php [new file with mode: 0644]
auth/ldap/settings.php
auth/ldap/tests/plugin_test.php
auth/ldap/upgrade.txt
auth/ldap/version.php
availability/condition/completion/classes/frontend.php
availability/condition/completion/lang/en/availability_completion.php
availability/condition/completion/yui/build/moodle-availability_completion-form/moodle-availability_completion-form-debug.js
availability/condition/completion/yui/build/moodle-availability_completion-form/moodle-availability_completion-form-min.js
availability/condition/completion/yui/build/moodle-availability_completion-form/moodle-availability_completion-form.js
availability/condition/completion/yui/src/form/js/form.js
availability/condition/grade/tests/behat/availability_grade.feature
availability/tests/behat/edit_availability.feature
backup/moodle2/restore_stepslib.php
backup/moodle2/tests/restore_stepslib_date_test.php [new file with mode: 0644]
backup/util/helper/backup_anonymizer_helper.class.php
backup/util/plan/backup_plan.class.php
backup/util/plan/restore_step.class.php
backup/util/ui/tests/behat/restore_moodle2_courses.feature
badges/criteria/award_criteria_activity.php
badges/criteria/award_criteria_course.php
blocks/activity_modules/tests/behat/block_activity_modules.feature
blocks/activity_results/tests/behat/addunsupportedactivity.feature
blocks/blog_recent/tests/behat/block_blog_recent_course.feature
blocks/calendar_month/tests/behat/block_calendar_month.feature
blocks/calendar_upcoming/tests/behat/block_calendar_upcoming_course.feature
blocks/calendar_upcoming/tests/behat/block_calendar_upcoming_dashboard.feature
blocks/calendar_upcoming/tests/behat/block_calendar_upcoming_frontpage.feature
blocks/community/forms.php
blocks/completionstatus/tests/behat/block_completionstatus_activity_completion.feature
blocks/myoverview/classes/output/courses_view.php
blocks/myoverview/templates/course-event-list.mustache
blocks/myoverview/templates/course-summary.mustache
blocks/myoverview/templates/courses-view-course-item.mustache
blocks/participants/tests/behat/block_participants_course.feature
calendar/amd/build/calendar.min.js
calendar/amd/build/calendar_events.min.js [deleted file]
calendar/amd/build/calendar_repository.min.js [deleted file]
calendar/amd/build/event_form.min.js [new file with mode: 0644]
calendar/amd/build/events.min.js [new file with mode: 0644]
calendar/amd/build/modal_event_form.min.js [new file with mode: 0644]
calendar/amd/build/repository.min.js [new file with mode: 0644]
calendar/amd/build/summary_modal.min.js
calendar/amd/build/view_manager.min.js [new file with mode: 0644]
calendar/amd/src/calendar.js
calendar/amd/src/event_form.js [new file with mode: 0644]
calendar/amd/src/events.js [moved from calendar/amd/src/calendar_events.js with 70% similarity]
calendar/amd/src/modal_event_form.js [new file with mode: 0644]
calendar/amd/src/repository.js [moved from calendar/amd/src/calendar_repository.js with 61% similarity]
calendar/amd/src/summary_modal.js
calendar/amd/src/view_manager.js [new file with mode: 0644]
calendar/classes/external/calendar_event_exporter.php [new file with mode: 0644]
calendar/classes/external/day_exporter.php [new file with mode: 0644]
calendar/classes/external/day_name_exporter.php [new file with mode: 0644]
calendar/classes/external/event_exporter.php
calendar/classes/external/event_exporter_base.php [new file with mode: 0644]
calendar/classes/external/footer_options_exporter.php [new file with mode: 0644]
calendar/classes/external/month_exporter.php [new file with mode: 0644]
calendar/classes/external/week_exporter.php [new file with mode: 0644]
calendar/classes/local/api.php
calendar/classes/local/event/forms/create.php [new file with mode: 0644]
calendar/classes/local/event/forms/update.php [new file with mode: 0644]
calendar/classes/local/event/mappers/create_update_form_mapper.php [new file with mode: 0644]
calendar/classes/local/event/mappers/create_update_form_mapper_interface.php [new file with mode: 0644]
calendar/classes/local/event/mappers/event_mapper.php
calendar/classes/type_base.php
calendar/delete.php
calendar/externallib.php
calendar/lib.php
calendar/renderer.php
calendar/templates/event_summary_body.mustache
calendar/templates/event_summary_modal.mustache
calendar/templates/footer_options.mustache [new file with mode: 0644]
calendar/templates/modal_event_form.mustache [new file with mode: 0644]
calendar/templates/month_detailed.mustache [new file with mode: 0644]
calendar/templates/month_header.mustache [new file with mode: 0644]
calendar/templates/month_navigation.mustache [new file with mode: 0644]
calendar/tests/behat/behat_calendar.php
calendar/tests/behat/calendar.feature
calendar/tests/behat/calendar_import.feature
calendar/tests/behat/calendar_lookahead.feature
calendar/tests/externallib_test.php
calendar/tests/lib_test.php
calendar/view.php
completion/tests/behat/bulk_edit_activity_completion.feature
completion/tests/behat/default_activity_completion.feature
course/classes/management/helper.php
course/classes/output/activity_navigation.php [new file with mode: 0644]
course/edit.php
course/format/topics/tests/behat/edit_delete_sections.feature
course/format/weeks/tests/behat/edit_delete_sections.feature
course/lib.php
course/modedit.php
course/modlib.php
course/renderer.php
course/templates/activity_navigation.mustache [new file with mode: 0644]
course/tests/behat/activity_navigation.feature [new file with mode: 0644]
course/tests/behat/activity_navigation_with_restrictions.feature [new file with mode: 0644]
course/tests/behat/behat_course.php
course/tests/behat/navigate_course_list.feature
course/tests/behat/paged_course_navigation.feature
course/tests/behat/rename_roles.feature
course/tests/courselib_test.php
enrol/bulkchange.php [deleted file]
enrol/cohort/lib.php
enrol/database/lib.php
enrol/flatfile/lib.php
enrol/locallib.php
enrol/lti/lib.php
enrol/manual/lib.php
enrol/manual/manage.php
enrol/meta/lib.php
enrol/meta/tests/behat/enrol_meta.feature
enrol/paypal/lib.php
enrol/renderer.php
enrol/self/lib.php
enrol/tests/behat/add_to_group.feature
enrol/tests/behat/enrol_user.feature
enrol/tests/behat/filter_enrolled_users.feature [deleted file]
enrol/tests/behat/manage_enrolments_from_participants.feature [deleted file]
enrol/upgrade.txt
enrol/users.php [deleted file]
grade/report/grader/lib.php
grade/report/lib.php
grade/tests/behat/grade_average.feature
grade/tests/behat/grade_hidden_items.feature
grade/tests/behat/grade_minmax.feature
group/classes/output/user_groups_editable.php
group/tests/behat/create_groups.feature
install/lang/de/error.php
install/lang/el/moodle.php
install/lang/es_mx_kids/langconfig.php
install/lang/fa/error.php
install/lang/ru/install.php
install/lang/sr_cr/install.php
install/lang/sr_lt/install.php
lang/en/admin.php
lang/en/analytics.php
lang/en/calendar.php
lang/en/deprecated.txt
lang/en/form.php
lang/en/hub.php
lang/en/moodle.php
lang/en/role.php
lang/en/webservice.php
lib/adminlib.php
lib/amd/build/form-autocomplete.min.js
lib/amd/build/modal.min.js
lib/amd/build/modal_factory.min.js
lib/amd/src/form-autocomplete.js
lib/amd/src/modal.js
lib/amd/src/modal_factory.js
lib/authlib.php
lib/blocklib.php
lib/classes/analytics/target/no_teaching.php
lib/classes/event/config_log_created.php [new file with mode: 0644]
lib/classes/event/course_backup_created.php [new file with mode: 0644]
lib/classes/event/user_enrolment_created.php
lib/classes/event/user_enrolment_deleted.php
lib/classes/lock/installation_lock_factory.php [new file with mode: 0644]
lib/classes/lock/lock_config.php
lib/classes/oauth2/api.php
lib/classes/output/icon_system_fontawesome.php
lib/datalib.php
lib/db/access.php
lib/db/install.xml
lib/db/services.php
lib/db/upgrade.php
lib/ddl/mysql_sql_generator.php
lib/dml/mariadb_native_moodle_database.php
lib/dml/mysqli_native_moodle_database.php
lib/enrollib.php
lib/external/externallib.php
lib/filestorage/file_storage.php
lib/filestorage/stored_file.php
lib/form/filemanager.php
lib/form/filepicker.php
lib/form/submit.php
lib/form/tests/behat/filetypes.feature [deleted file]
lib/form/tests/behat/multi_select_dependencies.feature [deleted file]
lib/form/tests/fixtures/filetypes.php [deleted file]
lib/form/tests/fixtures/multi_select_dependencies.php [deleted file]
lib/form/yui/build/moodle-form-dateselector/moodle-form-dateselector-debug.js
lib/form/yui/build/moodle-form-dateselector/moodle-form-dateselector-min.js
lib/form/yui/build/moodle-form-dateselector/moodle-form-dateselector.js
lib/form/yui/src/dateselector/js/dateselector.js
lib/grade/grade_grade.php
lib/grouplib.php
lib/moodlelib.php
lib/oauthlib.php
lib/outputrenderers.php
lib/phpunit/classes/restore_date_testcase.php [new file with mode: 0644]
lib/templates/single_button.mustache [new file with mode: 0644]
lib/tests/admintree_test.php
lib/tests/behat/alpha_chooser.feature
lib/tests/grouplib_test.php
lib/tests/statslib_test.php
lib/upgrade.txt
lib/weblib.php
media/player/youtube/classes/plugin.php
media/player/youtube/tests/player_test.php
message/output/airnotifier/lang/en/message_airnotifier.php
message/output/airnotifier/requestaccesskey.php
mod/assign/backup/moodle2/restore_assign_stepslib.php
mod/assign/lang/en/assign.php
mod/assign/locallib.php
mod/assign/mod_form.php
mod/assign/submission/file/lang/en/assignsubmission_file.php
mod/assign/submission/file/lang/en/deprecated.txt [new file with mode: 0644]
mod/assign/submission/file/locallib.php
mod/assign/submission/file/settings.php
mod/assign/submission/file/tests/behat/file_type_restriction.feature
mod/assign/submission/file/tests/locallib_test.php
mod/assign/tests/behat/assign_group_override.feature
mod/assign/tests/behat/assign_user_override.feature
mod/assign/tests/restore_date_test.php [new file with mode: 0644]
mod/book/backup/moodle2/restore_book_stepslib.php
mod/book/lib.php
mod/book/tests/behat/edit_tags.feature
mod/book/view.php
mod/chat/backup/moodle2/restore_chat_stepslib.php
mod/chat/lib.php
mod/chat/tests/restore_date_test.php [new file with mode: 0644]
mod/choice/backup/moodle2/restore_choice_stepslib.php
mod/choice/lib.php
mod/choice/tests/restore_date_test.php [new file with mode: 0644]
mod/data/backup/moodle2/restore_data_stepslib.php
mod/data/classes/external.php
mod/data/classes/external/content_exporter.php
mod/data/lib.php
mod/data/locallib.php
mod/data/tests/behat/completion_condition_entries.feature
mod/data/tests/externallib_test.php
mod/data/tests/restore_date_test.php [new file with mode: 0644]
mod/data/upgrade.txt
mod/feedback/backup/moodle1/lib.php
mod/feedback/backup/moodle2/restore_feedback_stepslib.php
mod/feedback/lib.php
mod/feedback/tests/behat/anonymous.feature
mod/feedback/tests/behat/coursemapping.feature
mod/feedback/tests/behat/multipleattempt.feature
mod/feedback/tests/restore_date_test.php [new file with mode: 0644]
mod/folder/backup/moodle2/restore_folder_stepslib.php
mod/folder/lib.php
mod/folder/tests/restore_date_test.php [new file with mode: 0644]
mod/forum/backup/moodle2/restore_forum_stepslib.php
mod/forum/discuss.php
mod/forum/lib.php
mod/forum/tests/behat/posts_ordering_blog.feature
mod/forum/tests/restore_date_test.php [new file with mode: 0644]
mod/forum/user.php
mod/glossary/backup/moodle2/restore_glossary_stepslib.php
mod/glossary/lib.php
mod/glossary/tests/behat/edit_tags.feature
mod/glossary/tests/restore_date_test.php [new file with mode: 0644]
mod/imscp/backup/moodle2/restore_imscp_stepslib.php
mod/imscp/lib.php
mod/imscp/tests/restore_date_test.php [new file with mode: 0644]
mod/label/backup/moodle2/restore_label_stepslib.php
mod/label/lib.php
mod/lesson/backup/moodle2/restore_lesson_stepslib.php
mod/lesson/continue.php
mod/lesson/lib.php
mod/lesson/locallib.php
mod/lesson/tests/behat/duplicate_lesson_page.feature
mod/lesson/tests/behat/import_fillintheblank_question.feature
mod/lesson/tests/behat/import_images.feature
mod/lesson/tests/behat/questions_images.feature
mod/lesson/tests/lib_test.php
mod/lesson/tests/restore_date_test.php [new file with mode: 0644]
mod/lti/backup/moodle2/restore_lti_stepslib.php
mod/lti/launch.php
mod/lti/view.php
mod/page/backup/moodle2/restore_page_stepslib.php
mod/page/lib.php
mod/quiz/backup/moodle2/restore_quiz_stepslib.php
mod/quiz/lib.php
mod/quiz/mod_form.php
mod/quiz/tests/behat/completion_condition_attempts_used.feature
mod/quiz/tests/behat/completion_condition_passing_grade.feature
mod/quiz/tests/restore_date_test.php [new file with mode: 0644]
mod/resource/backup/moodle2/restore_resource_stepslib.php
mod/resource/lib.php
mod/resource/tests/restore_date_test.php [new file with mode: 0644]
mod/resource/view.php
mod/scorm/backup/moodle2/restore_scorm_stepslib.php
mod/scorm/lib.php
mod/scorm/tests/restore_date_test.php [new file with mode: 0644]
mod/survey/backup/moodle2/restore_survey_stepslib.php
mod/survey/lib.php
mod/survey/tests/restore_date_test.php [new file with mode: 0644]
mod/upgrade.txt
mod/url/backup/moodle2/restore_url_stepslib.php
mod/url/lib.php
mod/url/view.php
mod/wiki/backup/moodle2/restore_wiki_stepslib.php
mod/wiki/lib.php
mod/wiki/tests/restore_date_test.php [new file with mode: 0644]
mod/workshop/backup/moodle2/restore_workshop_stepslib.php
mod/workshop/lib.php
mod/workshop/tests/restore_date_test.php [new file with mode: 0644]
report/log/lib.php
repository/boxnet/lib.php
repository/dropbox/lib.php
repository/equella/lib.php
repository/filesystem/lib.php
repository/lib.php
repository/upgrade.txt
theme/boost/scss/moodle/calendar.scss
theme/boost/scss/moodle/core.scss
theme/boost/scss/moodle/question.scss
theme/boost/scss/moodle/search.scss
theme/boost/scss/moodle/undo.scss
theme/boost/scss/preset/default.scss
theme/boost/templates/columns1.mustache
theme/boost/templates/columns2.mustache
theme/boost/templates/core/modal.mustache
theme/boost/upgrade.txt [new file with mode: 0644]
theme/bootstrapbase/layout/columns1.php
theme/bootstrapbase/layout/columns3.php
theme/bootstrapbase/less/moodle.less
theme/bootstrapbase/less/moodle/bs4-compat.less
theme/bootstrapbase/less/moodle/calendar.less
theme/bootstrapbase/less/moodle/modal.less
theme/bootstrapbase/less/moodle/modules.less
theme/bootstrapbase/less/moodle/question.less
theme/bootstrapbase/less/moodle/variables.less [new file with mode: 0644]
theme/bootstrapbase/style/moodle.css
theme/bootstrapbase/templates/block_myoverview/course-event-list.mustache
theme/bootstrapbase/templates/block_myoverview/course-summary.mustache
theme/bootstrapbase/templates/block_myoverview/courses-view-course-item.mustache
theme/clean/layout/columns1.php
theme/clean/layout/columns3.php
theme/upgrade.txt
user/action_redir.php
user/amd/build/status_field.min.js
user/amd/build/unified_filter.min.js [new file with mode: 0644]
user/amd/build/unified_filter_datasource.min.js [new file with mode: 0644]
user/amd/src/status_field.js
user/amd/src/unified_filter.js [new file with mode: 0644]
user/amd/src/unified_filter_datasource.js [new file with mode: 0644]
user/classes/output/unified_filter.php [new file with mode: 0644]
user/classes/participants_table.php
user/index.php
user/lib.php
user/renderer.php
user/templates/status_details.mustache
user/templates/unified_filter.mustache [new file with mode: 0644]
user/tests/behat/bulk_editenrolment.feature [new file with mode: 0644]
user/tests/behat/course_preference.feature
user/tests/behat/edit_user_enrolment.feature
user/tests/behat/edit_user_roles.feature
user/tests/behat/filter_participants.feature [new file with mode: 0644]
user/tests/behat/set_default_homepage.feature
user/tests/userlib_test.php
version.php
webservice/classes/token_table.php [new file with mode: 0644]
webservice/lib.php

index ae3c709..ba8a49e 100644 (file)
@@ -203,7 +203,7 @@ if (!empty($options['collation'])) {
                 $DB->change_database_structure($sql);
             } else {
                 echo "ERROR (unknown column type: $column->type)\n";
-                $error++;
+                $errors++;
                 continue;
             }
             echo "CONVERTED\n";
index 8142999..4cb0e40 100644 (file)
@@ -128,7 +128,7 @@ class hub_selector_form extends moodleform {
 
         //remove moodle.org from the hub list
         foreach ($hubs as $key => $hub) {
-            if ($hub['url'] == HUB_MOODLEORGHUBURL) {
+            if ($hub['url'] == HUB_MOODLEORGHUBURL || $hub['url'] == HUB_OLDMOODLEORGHUBURL) {
                 unset($hubs[$key]);
             }
         }
index 8b33014..b9aeb18 100644 (file)
@@ -181,13 +181,8 @@ if (empty($cancel) and $unregistration and !$confirm) {
     echo $OUTPUT->header();
 
     //check if the site is registered on Moodle.org and display a message about registering on MOOCH
-    $registered = $DB->count_records('registration_hubs', array('huburl' => HUB_MOODLEORGHUBURL, 'confirmed' => 1));
-    if (empty($registered)) {
-        $warningmsg = get_string('registermoochtips', 'hub');
-        $warningmsg .= $renderer->single_button(new moodle_url('register.php', array('huburl' => HUB_MOODLEORGHUBURL
-                    , 'hubname' => 'Moodle.org')), get_string('register', 'admin'));
-        echo $renderer->box($warningmsg, 'buttons mdl-align generalbox adminwarning');
-    }
+    $adminrenderer = $PAGE->get_renderer('core', 'admin');
+    echo $adminrenderer->warn_if_not_registered();
 
     //do not check sesskey if confirm = false because this script is linked into email message
     if (!empty($errormessage)) {
index 895ae5c..43d2e42 100644 (file)
@@ -189,17 +189,19 @@ if (!empty($error)) {
 
 // Some Moodle.org registration explanation.
 if ($huburl == HUB_MOODLEORGHUBURL) {
+    $notificationtype = \core\output\notification::NOTIFY_ERROR;
     if (!empty($registeredhub->token)) {
         if ($registeredhub->timemodified == 0) {
             $registrationmessage = get_string('pleaserefreshregistrationunknown', 'admin');
         } else {
             $lastupdated = userdate($registeredhub->timemodified, get_string('strftimedate', 'langconfig'));
             $registrationmessage = get_string('pleaserefreshregistration', 'admin', $lastupdated);
+            $notificationtype = \core\output\notification::NOTIFY_INFO;
         }
     } else {
         $registrationmessage = get_string('registrationwarning', 'admin');
     }
-    echo $OUTPUT->notification($registrationmessage);
+    echo $OUTPUT->notification($registrationmessage, $notificationtype);
 
     echo $OUTPUT->heading(get_string('registerwithmoodleorg', 'admin'));
     $renderer = $PAGE->get_renderer('core', 'register');
index 2d6c362..63cc1ad 100644 (file)
@@ -782,17 +782,36 @@ class core_admin_renderer extends plugin_renderer_base {
 
         if (!$registered) {
 
-            $registerbutton = $this->single_button(new moodle_url('/admin/registration/register.php',
-                    array('huburl' =>  HUB_MOODLEORGHUBURL, 'hubname' => 'Moodle.org')),
+            if (has_capability('moodle/site:config', context_system::instance())) {
+                $registerbutton = $this->single_button(new moodle_url('/admin/registration/register.php',
+                    array('huburl' =>  HUB_MOODLEORGHUBURL, 'hubname' => 'Moodle.net')),
                     get_string('register', 'admin'));
+                $str = 'registrationwarning';
+            } else {
+                $registerbutton = '';
+                $str = 'registrationwarningcontactadmin';
+            }
 
-            return $this->warning( get_string('registrationwarning', 'admin')
-                    . '&nbsp;' . $this->help_icon('registration', 'admin') . $registerbutton );
+            return $this->warning( get_string($str, 'admin')
+                    . '&nbsp;' . $this->help_icon('registration', 'admin') . $registerbutton ,
+                'error alert alert-danger');
         }
 
         return '';
     }
 
+    /**
+     * Return an admin page warning if site is not registered with moodle.org
+     *
+     * @return string
+     */
+    public function warn_if_not_registered() {
+        global $CFG;
+        require_once($CFG->dirroot . '/' . $CFG->admin . '/registration/lib.php');
+        $registrationmanager = new registration_manager();
+        return $this->registration_warning($registrationmanager->get_registeredhub(HUB_MOODLEORGHUBURL) ? true : false);
+    }
+
     /**
      * Helper method to render the information about the available Moodle update
      *
index 7dd8e2c..fb38431 100644 (file)
@@ -38,6 +38,12 @@ if ($data = data_submitted() and confirm_sesskey() and isset($data->action) and
 // to modify them
 echo $OUTPUT->header($focus);
 
+// Display a warning if site is not registered.
+if (empty($query)) {
+    $adminrenderer = $PAGE->get_renderer('core', 'admin');
+    echo $adminrenderer->warn_if_not_registered();
+}
+
 echo $OUTPUT->heading(get_string('administrationsite'));
 
 if ($errormsg !== '') {
index a1ac0b1..17724a3 100644 (file)
@@ -84,7 +84,7 @@ if ($hassiteconfig) {
             $timesplittingoptions[$key] = $timesplitting->get_name();
         }
         $settings->add(new admin_setting_configmultiselect('analytics/timesplittings',
-            new lang_string('enabledtimesplittings', 'analytics'), new lang_string('enabledtimesplittings_help', 'analytics'),
+            new lang_string('enabledtimesplittings', 'analytics'), new lang_string('timesplittingmethod_help', 'analytics'),
             $timesplittingdefaults, $timesplittingoptions)
         );
 
index 5d4495c..c78c909 100644 (file)
@@ -11,7 +11,7 @@ $hassiteconfig = has_capability('moodle/site:config', $systemcontext);
 $ADMIN->add('root', new admin_externalpage('adminnotifications', new lang_string('notifications'), "$CFG->wwwroot/$CFG->admin/index.php"));
 
 $ADMIN->add('root', new admin_externalpage('registrationmoodleorg', new lang_string('registration', 'admin'),
-        "$CFG->wwwroot/$CFG->admin/registration/register.php?huburl=" . HUB_MOODLEORGHUBURL . "&hubname=Moodle.org&sesskey=" . sesskey()));
+        "$CFG->wwwroot/$CFG->admin/registration/register.php?huburl=" . HUB_MOODLEORGHUBURL . "&hubname=Moodle.net&sesskey=" . sesskey()));
 $ADMIN->add('root', new admin_externalpage('registrationhub', new lang_string('registerwith', 'hub'),
         "$CFG->wwwroot/$CFG->admin/registration/register.php", 'moodle/site:config', true));
 $ADMIN->add('root', new admin_externalpage('registrationhubs', new lang_string('hubs', 'admin'),
diff --git a/admin/tests/behat/manage_tokens.feature b/admin/tests/behat/manage_tokens.feature
new file mode 100644 (file)
index 0000000..d30e230
--- /dev/null
@@ -0,0 +1,26 @@
+@core @core_admin
+Feature: Manage tokens
+  In order to manage webservice usage
+  As an admin
+  I need to be able to create and delete tokens
+
+  Background:
+    Given the following "users" exist:
+    | username  | password  | firstname | lastname |
+    | testuser  | testuser  | Joe | Bloggs |
+    | testuser2 | testuser2 | TestFirstname | TestLastname |
+    And I log in as "admin"
+    And I am on site homepage
+
+  @javascript
+  Scenario: Add & delete a token
+    Given I navigate to "Plugins > Web services > Manage tokens" in site administration
+    And I follow "Add"
+    And I set the field "User" to "Joe Bloggs"
+    And I set the field "IP restriction" to "127.0.0.1"
+    When I press "Save changes"
+    Then I should see "Joe Bloggs"
+    And I should see "127.0.0.1"
+    And I follow "Delete"
+    And I press "Delete"
+    And I should not see "Joe Bloggs"
index 181b48d..d524595 100644 (file)
@@ -117,6 +117,20 @@ class models_list implements \renderable, \templatable {
                 $actionsmenu->add($icon);
             }
 
+            // Enable / disable.
+            if ($model->is_enabled()) {
+                $action = 'disable';
+                $text = get_string('disable');
+                $icontype = 't/block';
+            } else {
+                $action = 'enable';
+                $text = get_string('enable');
+                $icontype = 'i/checked';
+            }
+            $url = new \moodle_url('model.php', array('action' => $action, 'id' => $model->get_id()));
+            $icon = new \action_menu_link_secondary($url, new \pix_icon($icontype, $text), $text);
+            $actionsmenu->add($icon);
+
             // Evaluate machine-learning-based models.
             if ($model->get_indicators() && !$model->is_static()) {
                 $url = new \moodle_url('model.php', array('action' => 'evaluate', 'id' => $model->get_id()));
@@ -125,6 +139,7 @@ class models_list implements \renderable, \templatable {
                 $actionsmenu->add($icon);
             }
 
+            // Get predictions.
             if ($modeldata->enabled && !empty($modeldata->timesplitting)) {
                 $url = new \moodle_url('model.php', array('action' => 'getpredictions', 'id' => $model->get_id()));
                 $icon = new \action_menu_link_secondary($url, new \pix_icon('i/notifications',
@@ -140,6 +155,14 @@ class models_list implements \renderable, \templatable {
                 $actionsmenu->add($icon);
             }
 
+            // Export training data.
+            if (!$model->is_static() && $model->is_trained()) {
+                $url = new \moodle_url('model.php', array('action' => 'export', 'id' => $model->get_id()));
+                $icon = new \action_menu_link_secondary($url, new \pix_icon('i/export',
+                    get_string('exporttrainingdata', 'tool_analytics')), get_string('export', 'tool_analytics'));
+                $actionsmenu->add($icon);
+            }
+
             $modeldata->actions = $actionsmenu->export_for_template($output);
 
             $data->models[] = $modeldata;
index eba2a8f..4922a51 100644 (file)
@@ -36,13 +36,16 @@ $string['enabled'] = 'Enabled';
 $string['errorcantenablenotimesplitting'] = 'You need to select a time splitting method before enabling the model';
 $string['errornoenabledandtrainedmodels'] = 'There are not enabled and trained models to predict';
 $string['errornoenabledmodels'] = 'There are not enabled models to train';
+$string['errornoexport'] = 'Only trained models can be exported';
 $string['errornostaticedit'] = 'Models based on assumptions can not be edited';
 $string['errornostaticevaluated'] = 'Models based on assumptions can not be evaluated, they are always 100% correct according to how they were defined';
 $string['errornostaticlog'] = 'Models based on assumptions can not be evaluated, there is no preformance log';
+$string['errortrainingdataexport'] = 'The model training data could not be exported';
 $string['evaluate'] = 'Evaluate';
 $string['evaluatemodel'] = 'Evaluate model';
 $string['evaluationinbatches'] = 'The site contents are calculated and stored in batches, during evaluation you can stop the process at any moment, the next time you run it it will continue from the point you stopped it.';
-$string['trainandpredictmodel'] = 'Training model and calculating predictions';
+$string['export'] = 'Export';
+$string['exporttrainingdata'] = 'Export training data';
 $string['getpredictionsresultscli'] = 'Results using {$a->name} (id: {$a->id}) course duration splitting';
 $string['getpredictionsresults'] = 'Results using {$a->name} course duration splitting';
 $string['extrainfo'] = 'Info';
@@ -68,6 +71,7 @@ $string['predictionprocessfinished'] = 'Prediction process finished';
 $string['samestartdate'] = 'Current start date is good';
 $string['sameenddate'] = 'Current end date is good';
 $string['target'] = 'Target';
+$string['trainandpredictmodel'] = 'Training model and calculating predictions';
 $string['trainingprocessfinished'] = 'Training process finished';
 $string['trainingresults'] = 'Training results';
 $string['trainmodels'] = 'Train models';
index 2c64179..daf62cf 100644 (file)
@@ -51,6 +51,16 @@ switch ($action) {
     case 'log':
         $title = get_string('viewlog', 'tool_analytics');
         break;
+    case 'enable':
+        $title = get_string('enable');
+        break;
+    case 'disable':
+        $title = get_string('disable');
+        break;
+    case 'export':
+        $title = get_string('export', 'tool_analytics');
+        break;
+
     default:
         throw new moodle_exception('errorunknownaction', 'analytics');
 }
@@ -63,6 +73,14 @@ $PAGE->set_heading($title);
 
 switch ($action) {
 
+    case 'enable':
+        $model->enable();
+        redirect(new \moodle_url('/admin/tool/analytics/index.php'));
+
+    case 'disable':
+        $model->update(0, false, false);
+        redirect(new \moodle_url('/admin/tool/analytics/index.php'));
+
     case 'edit':
 
         if ($model->is_static()) {
@@ -147,6 +165,22 @@ switch ($action) {
         $modellogstable = new \tool_analytics\output\model_logs('model-' . $model->get_id(), $model);
         echo $renderer->render_table($modellogstable);
         break;
+
+    case 'export':
+
+        if ($model->is_static() || !$model->is_trained()) {
+            throw new moodle_exception('errornoexport', 'tool_analytics');
+        }
+
+        $file = $model->get_training_data();
+        if (!$file) {
+            redirect(new \moodle_url('/admin/tool/analytics/index.php'), get_string('errortrainingdataexport', 'tool_analytics'),
+                null, \core\output\notification::NOTIFY_ERROR);
+        }
+
+        $filename = 'training-data.' . $model->get_id() . '.' . time() . '.csv';
+        send_file($file, $filename, null, 0, false, true);
+        break;
 }
 
 echo $OUTPUT->footer();
index 8667fa9..9bcda1f 100644 (file)
@@ -24,9 +24,6 @@
 
 defined('MOODLE_INTERNAL') || die();
 
-global $CFG;
-require_once($CFG->libdir . '/behat/classes/behat_selectors.php');
-
 /**
  * Renderer for behat tool web features
  *
@@ -44,6 +41,8 @@ class tool_behat_renderer extends plugin_renderer_base {
      * @return string HTML code
      */
     public function render_stepsdefinitions($stepsdefinitions, $form) {
+        global $CFG;
+        require_once($CFG->libdir . '/behat/classes/behat_selectors.php');
 
         $html = $this->generic_info();
 
index d8a325b..0e62104 100644 (file)
@@ -221,7 +221,7 @@ Feature: Set up contextual data for tests
     And I should see "Test workshop name"
     And I follow "Test assignment name"
     And I should see "Test assignment description"
-    And I follow "C1"
+    And I am on "Course 1" course homepage
     And I follow "Test assignment name with scale"
     And I follow "Edit settings"
     And the field "Type" matches value "Scale"
@@ -312,7 +312,6 @@ Feature: Set up contextual data for tests
       | fullname | course | gradecategory |
       | Grade sub category 2 | C1 | Grade category 1 |
     When I log in as "admin"
-    And I am on course index
     And I am on "Course 1" course homepage
     And I navigate to "View > Grader report" in the course gradebook
     Then I should see "Grade category 1"
index a38a75d..de818df 100644 (file)
@@ -17,7 +17,6 @@ Feature: tool_monitor_rule
     And I log in as "admin"
     And I navigate to "Event monitoring rules" node in "Site administration > Reports"
     And I click on "Enable" "link"
-    And I am on site homepage
     And I am on "Course 1" course homepage
     And I navigate to "Event monitoring rules" node in "Course administration > Reports"
     And I press "Add a new rule"
index 80ab6aa..d10fa93 100644 (file)
@@ -60,6 +60,8 @@ class tool_recyclebin_events_testcase extends advanced_testcase {
         delete_course($course, false);
         $events = $sink->get_events();
         $event = reset($events);
+        // Need the second event here, the first is backup created.
+        $event = next($events);
 
         // Get the item from the recycle bin.
         $rb = new \tool_recyclebin\category_bin($course->category);
index 60ea271..f15f7c2 100644 (file)
@@ -101,7 +101,11 @@ switch ($action) {
         break;
 
     case 'delete':
-        $token = $webservicemanager->get_created_by_user_ws_token($USER->id, $tokenid);
+        $token = $webservicemanager->get_token_by_id_with_details($tokenid);
+
+        if ($token->creatorid != $USER->id) {
+            require_capability("moodle/webservice:managealltokens", context_system::instance());
+        }
 
         //Delete the token
         if ($confirm and confirm_sesskey()) {
index a1438ea..882d8dc 100644 (file)
@@ -45,6 +45,11 @@ class dataset_manager {
      */
     const UNLABELLED_FILEAREA = 'unlabelled';
 
+    /**
+     * File area for exported datasets.
+     */
+    const EXPORT_FILEAREA = 'export';
+
     /**
      * Evaluation file file name.
      */
@@ -77,28 +82,35 @@ class dataset_manager {
     protected $evaluation;
 
     /**
-     * Labelled (true) or unlabelled data (false).
+     * The dataset filearea. Must be one of the self::*_FILEAREA options.
      *
-     * @var bool
+     * @var string
      */
-    protected $includetarget;
+    protected $filearea;
 
     /**
      * Constructor method.
      *
+     * @throws \coding_exception
      * @param int $modelid
      * @param int $analysableid
      * @param string $timesplittingid
+     * @param string $filearea
      * @param bool $evaluation
-     * @param bool $includetarget
      * @return void
      */
-    public function __construct($modelid, $analysableid, $timesplittingid, $evaluation = false, $includetarget = false) {
+    public function __construct($modelid, $analysableid, $timesplittingid, $filearea, $evaluation = false) {
+
+        if ($filearea !== self::EXPORT_FILEAREA && $filearea !== self::LABELLED_FILEAREA &&
+                $filearea !== self::UNLABELLED_FILEAREA) {
+            throw new \coding_exception('Invalid provided filearea');
+        }
+
         $this->modelid = $modelid;
         $this->analysableid = $analysableid;
         $this->timesplittingid = $timesplittingid;
         $this->evaluation = $evaluation;
-        $this->includetarget = $includetarget;
+        $this->filearea = $filearea;
     }
 
     /**
@@ -108,8 +120,7 @@ class dataset_manager {
      */
     public function init_process() {
         $lockkey = 'modelid:' . $this->modelid . '-analysableid:' . $this->analysableid .
-            '-timesplitting:' . self::clean_time_splitting_id($this->timesplittingid) .
-            '-includetarget:' . (int)$this->includetarget;
+            '-timesplitting:' . self::clean_time_splitting_id($this->timesplittingid);
 
         // Large timeout as processes may be quite long.
         $lockfactory = \core\lock\lock_config::get_lock_factory('core_analytics');
@@ -132,9 +143,10 @@ class dataset_manager {
 
         // Delete previous file if it exists.
         $fs = get_file_storage();
+
         $filerecord = [
             'component' => 'analytics',
-            'filearea' => self::get_filearea($this->includetarget),
+            'filearea' => $this->filearea,
             'itemid' => $this->modelid,
             'contextid' => \context_system::instance()->id,
             'filepath' => '/analysable/' . $this->analysableid . '/' . self::clean_time_splitting_id($this->timesplittingid) . '/',
@@ -217,7 +229,7 @@ class dataset_manager {
         $fs = get_file_storage();
 
         // Always evaluation.csv and labelled as it is an evaluation file.
-        $filearea = self::get_filearea(true);
+        $filearea = self::LABELLED_FILEAREA;
         $filename = self::get_filename(true);
         $filepath = '/analysable/' . $analysableid . '/' . self::clean_time_splitting_id($timesplittingid) . '/';
         return $fs->get_file(\context_system::instance()->id, 'analytics', $filearea, $modelid, $filepath, $filename);
@@ -231,11 +243,11 @@ class dataset_manager {
      * @param array  $files
      * @param int    $modelid
      * @param string $timesplittingid
+     * @param string $filearea
      * @param bool   $evaluation
-     * @param bool   $includetarget
      * @return \stored_file
      */
-    public static function merge_datasets(array $files, $modelid, $timesplittingid, $evaluation, $includetarget) {
+    public static function merge_datasets(array $files, $modelid, $timesplittingid, $filearea, $evaluation = false) {
 
         $tmpfilepath = make_request_directory() . DIRECTORY_SEPARATOR . 'tmpfile.csv';
 
@@ -297,7 +309,7 @@ class dataset_manager {
 
         $filerecord = [
             'component' => 'analytics',
-            'filearea' => self::get_filearea($includetarget),
+            'filearea' => $filearea,
             'itemid' => $modelid,
             'contextid' => \context_system::instance()->id,
             'filepath' => '/timesplitting/' . self::clean_time_splitting_id($timesplittingid) . '/',
@@ -309,6 +321,37 @@ class dataset_manager {
         return $fs->create_file_from_pathname($filerecord, $tmpfilepath);
     }
 
+    /**
+     * Exports the model training data.
+     *
+     * @param int $modelid
+     * @param string $timesplittingid
+     * @return \stored_file|false
+     */
+    public static function export_training_data($modelid, $timesplittingid) {
+
+        $fs = get_file_storage();
+
+        $contextid = \context_system::instance()->id;
+        $filepath = '/timesplitting/' . self::clean_time_splitting_id($timesplittingid) . '/';
+
+        $files = $fs->get_directory_files($contextid, 'analytics', self::LABELLED_FILEAREA, $modelid,
+            $filepath, true, false);
+
+        // Discard evaluation files.
+        foreach ($files as $key => $file) {
+            if ($file->get_filename() === self::EVALUATION_FILENAME) {
+                unset($files[$key]);
+            }
+        }
+
+        if (empty($files)) {
+            return false;
+        }
+
+        return self::merge_datasets($files, $modelid, $timesplittingid, self::EXPORT_FILEAREA);
+    }
+
     /**
      * Returns the dataset file data structured by sampleids using the indicators and target column names.
      *
@@ -387,27 +430,9 @@ class dataset_manager {
             $filename = self::EVALUATION_FILENAME;
         } else {
             // Incremental time, the lock will make sure we don't have concurrency problems.
-            $filename = microtime(false) . '.csv';
+            $filename = microtime(true) . '.csv';
         }
 
         return $filename;
     }
-
-    /**
-     * Returns the file area to be used.
-     *
-     * @param bool $includetarget
-     * @return string
-     */
-    protected static function get_filearea($includetarget) {
-
-        if ($includetarget === true) {
-            $filearea = self::LABELLED_FILEAREA;
-        } else {
-            $filearea = self::UNLABELLED_FILEAREA;
-        }
-
-        return $filearea;
-    }
-
 }
index c80e66e..52986a4 100644 (file)
@@ -376,8 +376,8 @@ abstract class base {
             // All ranges are used when we are calculating data for training.
             $ranges = $timesplitting->get_all_ranges();
         } else {
-            // Only some ranges can be used for prediction (it depends on the time range where we are right now).
-            $ranges = $this->get_prediction_ranges($timesplitting);
+            // The latest range that has not yet been used for prediction (it depends on the time range where we are right now).
+            $ranges = $this->get_most_recent_prediction_range($timesplitting);
         }
 
         // There is no need to keep track of the evaluated samples and ranges as we always evaluate the whole dataset.
@@ -385,12 +385,12 @@ abstract class base {
 
             if (empty($ranges)) {
                 $result->status = \core_analytics\model::ANALYSABLE_REJECTED_TIME_SPLITTING_METHOD;
-                $result->message = get_string('nonewdata', 'analytics');
+                $result->message = get_string('noranges', 'analytics');
                 return $result;
             }
 
-            // We skip all samples that are already part of a training dataset, even if they have noe been used for training yet.
-            $sampleids = $this->filter_out_train_samples($sampleids, $timesplitting);
+            // We skip all samples that are already part of a training dataset, even if they have not been used for prediction.
+            $this->filter_out_train_samples($sampleids, $timesplitting);
 
             if (count($sampleids) === 0) {
                 $result->status = \core_analytics\model::ANALYSABLE_REJECTED_TIME_SPLITTING_METHOD;
@@ -400,19 +400,30 @@ abstract class base {
 
             // Only when processing data for predictions.
             if ($target === false) {
-                // We also filter out ranges that have already been used for predictions.
-                $ranges = $this->filter_out_prediction_ranges($ranges, $timesplitting);
+                // We also filter out samples and ranges that have already been used for predictions.
+                $this->filter_out_prediction_samples_and_ranges($sampleids, $ranges, $timesplitting);
+            }
+
+            if (count($sampleids) === 0) {
+                $result->status = \core_analytics\model::ANALYSABLE_REJECTED_TIME_SPLITTING_METHOD;
+                $result->message = get_string('nonewdata', 'analytics');
+                return $result;
             }
 
             if (count($ranges) === 0) {
                 $result->status = \core_analytics\model::ANALYSABLE_REJECTED_TIME_SPLITTING_METHOD;
-                $result->message = get_string('nonewtimeranges', 'analytics');
+                $result->message = get_string('nonewranges', 'analytics');
                 return $result;
             }
         }
 
+        if (!empty($target)) {
+            $filearea = \core_analytics\dataset_manager::LABELLED_FILEAREA;
+        } else {
+            $filearea = \core_analytics\dataset_manager::UNLABELLED_FILEAREA;
+        }
         $dataset = new \core_analytics\dataset_manager($this->modelid, $analysable->get_id(), $timesplitting->get_id(),
-            $this->options['evaluation'], !empty($target));
+            $filearea, $this->options['evaluation']);
 
         // Flag the model + analysable + timesplitting as being analysed (prevent concurrent executions).
         if (!$dataset->init_process()) {
@@ -469,7 +480,7 @@ abstract class base {
             if ($target) {
                 $this->save_train_samples($sampleids, $timesplitting, $file);
             } else {
-                $this->save_prediction_ranges($ranges, $timesplitting);
+                $this->save_prediction_samples($sampleids, $ranges, $timesplitting);
             }
         }
 
@@ -480,25 +491,28 @@ abstract class base {
     }
 
     /**
-     * Returns the ranges of a time splitting that can be used to predict.
+     * Returns the most recent range that can be used to predict.
      *
      * @param \core_analytics\local\time_splitting\base $timesplitting
      * @return array
      */
-    protected function get_prediction_ranges($timesplitting) {
+    protected function get_most_recent_prediction_range($timesplitting) {
 
         $now = time();
+        $ranges = $timesplitting->get_all_ranges();
+
+        // Opposite order as we are interested in the last range that can be used for prediction.
+        krsort($ranges);
 
         // We already provided the analysable to the time splitting method, there is no need to feed it back.
-        $predictionranges = array();
-        foreach ($timesplitting->get_all_ranges() as $rangeindex => $range) {
+        foreach ($ranges as $rangeindex => $range) {
             if ($timesplitting->ready_to_predict($range)) {
                 // We need to maintain the same indexes.
-                $predictionranges[$rangeindex] = $range;
+                return array($rangeindex => $range);
             }
         }
 
-        return $predictionranges;
+        return array();
     }
 
     /**
@@ -506,9 +520,8 @@ abstract class base {
      *
      * @param int[] $sampleids
      * @param \core_analytics\local\time_splitting\base $timesplitting
-     * @return int[]
      */
-    protected function filter_out_train_samples($sampleids, $timesplitting) {
+    protected function filter_out_train_samples(&$sampleids, $timesplitting) {
         global $DB;
 
         $params = array('modelid' => $this->modelid, 'analysableid' => $timesplitting->get_analysable()->get_id(),
@@ -526,32 +539,43 @@ abstract class base {
                 $sampleids = array_diff_key($sampleids, $usedsamples);
             }
         }
-
-        return $sampleids;
     }
 
     /**
      * Filters out samples that have already been used for prediction.
      *
+     * @param int[] $sampleids
      * @param array $ranges
      * @param \core_analytics\local\time_splitting\base $timesplitting
-     * @return int[]
      */
-    protected function filter_out_prediction_ranges($ranges, $timesplitting) {
+    protected function filter_out_prediction_samples_and_ranges(&$sampleids, &$ranges, $timesplitting) {
         global $DB;
 
+        if (count($ranges) > 1) {
+            throw new \coding_exception('$ranges argument should only contain one range');
+        }
+
+        $rangeindex = key($ranges);
+
         $params = array('modelid' => $this->modelid, 'analysableid' => $timesplitting->get_analysable()->get_id(),
-            'timesplitting' => $timesplitting->get_id());
+            'timesplitting' => $timesplitting->get_id(), 'rangeindex' => $rangeindex);
+        $predictedrange = $DB->get_record('analytics_predict_samples', $params);
 
-        $predictedranges = $DB->get_records('analytics_predict_ranges', $params);
-        foreach ($predictedranges as $predictedrange) {
-            if (!empty($ranges[$predictedrange->rangeindex])) {
-                unset($ranges[$predictedrange->rangeindex]);
-            }
+        if (!$predictedrange) {
+            // Nothing to filter out.
+            return;
         }
 
-        return $ranges;
+        $predictedrange->sampleids = json_decode($predictedrange->sampleids, true);
+        $missingsamples = array_diff_key($sampleids, $predictedrange->sampleids);
+        if (count($missingsamples) === 0) {
+            // All samples already calculated.
+            unset($ranges[$rangeindex]);
+            return;
+        }
 
+        // Replace the list of samples by the one excluding samples that already got predictions at this range.
+        $sampleids = $missingsamples;
     }
 
     /**
@@ -560,7 +584,7 @@ abstract class base {
      * @param int[] $sampleids
      * @param \core_analytics\local\time_splitting\base $timesplitting
      * @param \stored_file $file
-     * @return bool
+     * @return void
      */
     protected function save_train_samples($sampleids, $timesplitting, $file) {
         global $DB;
@@ -574,28 +598,40 @@ abstract class base {
         $trainingsamples->sampleids = json_encode($sampleids);
         $trainingsamples->timecreated = time();
 
-        return $DB->insert_record('analytics_train_samples', $trainingsamples);
+        $DB->insert_record('analytics_train_samples', $trainingsamples);
     }
 
     /**
      * Saves samples that have just been used for prediction.
      *
+     * @param int[] $sampleids
      * @param array $ranges
      * @param \core_analytics\local\time_splitting\base $timesplitting
      * @return void
      */
-    protected function save_prediction_ranges($ranges, $timesplitting) {
+    protected function save_prediction_samples($sampleids, $ranges, $timesplitting) {
         global $DB;
 
-        $predictionrange = new \stdClass();
-        $predictionrange->modelid = $this->modelid;
-        $predictionrange->analysableid = $timesplitting->get_analysable()->get_id();
-        $predictionrange->timesplitting = $timesplitting->get_id();
-        $predictionrange->timecreated = time();
+        if (count($ranges) > 1) {
+            throw new \coding_exception('$ranges argument should only contain one range');
+        }
+
+        $rangeindex = key($ranges);
 
-        foreach ($ranges as $rangeindex => $unused) {
-            $predictionrange->rangeindex = $rangeindex;
-            $DB->insert_record('analytics_predict_ranges', $predictionrange);
+        $params = array('modelid' => $this->modelid, 'analysableid' => $timesplitting->get_analysable()->get_id(),
+            'timesplitting' => $timesplitting->get_id(), 'rangeindex' => $rangeindex);
+        if ($predictionrange = $DB->get_record('analytics_predict_samples', $params)) {
+            // Append the new samples used for prediction.
+            $prevsamples = json_decode($predictionrange->sampleids, true);
+            $predictionrange->sampleids = json_encode($prevsamples + $sampleids);
+            $predictionrange->timemodified = time();
+            $DB->update_record('analytics_predict_samples', $predictionrange);
+        } else {
+            $predictionrange = (object)$params;
+            $predictionrange->sampleids = json_encode($sampleids);
+            $predictionrange->timecreated = time();
+            $predictionrange->timemodified = $predictionrange->timecreated;
+            $DB->insert_record('analytics_predict_samples', $predictionrange);
         }
     }
 }
index 424270e..e531bba 100644 (file)
@@ -111,8 +111,13 @@ abstract class by_course extends base {
             }
 
             // Merge all course files into one.
+            if ($includetarget) {
+                $filearea = \core_analytics\dataset_manager::LABELLED_FILEAREA;
+            } else {
+                $filearea = \core_analytics\dataset_manager::UNLABELLED_FILEAREA;
+            }
             $timesplittingfiles[$timesplittingid] = \core_analytics\dataset_manager::merge_datasets($files,
-                $this->modelid, $timesplittingid, $this->options['evaluation'], $includetarget);
+                $this->modelid, $timesplittingid, $filearea, $this->options['evaluation']);
         }
 
         return $timesplittingfiles;
index 1f21aca..9c6fb47 100644 (file)
@@ -57,8 +57,13 @@ abstract class sitewide extends base {
             }
 
             // We use merge but it is just a copy.
+            if ($includetarget) {
+                $filearea = \core_analytics\dataset_manager::LABELLED_FILEAREA;
+            } else {
+                $filearea = \core_analytics\dataset_manager::UNLABELLED_FILEAREA;
+            }
             $files[$timesplittingid] = \core_analytics\dataset_manager::merge_datasets(array($file), $this->modelid,
-                $timesplittingid, $this->options['evaluation'], $includetarget);
+                $timesplittingid, $filearea, $this->options['evaluation']);
         }
 
         return $files;
index f1b06b3..45fd2a8 100644 (file)
@@ -51,11 +51,11 @@ abstract class accumulative_parts extends base {
 
         $nparts = $this->get_number_parts();
 
-        $rangeduration = intval(floor(($this->analysable->get_end() - $this->analysable->get_start()) / $nparts));
+        $rangeduration = ($this->analysable->get_end() - $this->analysable->get_start()) / $nparts;
 
         $ranges = array();
         for ($i = 0; $i < $nparts; $i++) {
-            $end = $this->analysable->get_start() + ($rangeduration * ($i + 1));
+            $end = $this->analysable->get_start() + intval($rangeduration * ($i + 1));
             if ($i === ($nparts - 1)) {
                 // Better to use the end for the last one as we are using floor above.
                 $end = $this->analysable->get_end();
index 090e693..7d48858 100644 (file)
@@ -51,17 +51,25 @@ abstract class equal_parts extends base {
 
         $nparts = $this->get_number_parts();
 
-        $rangeduration = intval(floor(($this->analysable->get_end() - $this->analysable->get_start()) / $nparts));
+        $rangeduration = ($this->analysable->get_end() - $this->analysable->get_start()) / $nparts;
+
+        if ($rangeduration < $nparts) {
+            // It is interesting to avoid having a single timestamp belonging to multiple time ranges
+            // because of things like community of inquiry indicators, where activities have a due date
+            // that, ideally, would fall only into 1 time range. If the analysable duration is very short
+            // it is because the model doesn't contain indicators that depend so heavily on time and therefore
+            // we don't need to worry about timestamps being present in multiple time ranges.
+            $allowmultipleranges = true;
+        }
 
         $ranges = array();
         for ($i = 0; $i < $nparts; $i++) {
-            $start = $this->analysable->get_start() + ($rangeduration * $i);
-            $end = $this->analysable->get_start() + ($rangeduration * ($i + 1));
+            $start = $this->analysable->get_start() + intval($rangeduration * $i);
+            $end = $this->analysable->get_start() + intval($rangeduration * ($i + 1));
 
-            // Check the end of the previous time range.
-            if ($i > 0 && $start === $ranges[$i - 1]['end']) {
-                // We deduct 1 second from the previous end so each timestamp only belongs to 1 range.
-                $ranges[$i - 1]['end'] = $ranges[$i - 1]['end'] - 1;
+            if (empty($allowmultipleranges) && $i > 0 && $start === $ranges[$i - 1]['end']) {
+                // We add 1 second so each timestamp only belongs to 1 range.
+                $start = $start + 1;
             }
 
             if ($i === ($nparts - 1)) {
index 581f917..80f80cd 100644 (file)
@@ -84,15 +84,9 @@ class manager {
 
         $params = array();
 
-        $fields = 'am.id, am.enabled, am.trained, am.target, ' . $DB->sql_compare_text('am.indicators') .
-            ', am.timesplitting, am.version, am.timecreated, am.timemodified, am.usermodified';
-        $sql = "SELECT DISTINCT $fields FROM {analytics_models} am";
-        if ($predictioncontext) {
-            $sql .= " JOIN {analytics_predictions} ap ON ap.modelid = am.id AND ap.contextid = :contextid";
-            $params['contextid'] = $predictioncontext->id;
-        }
+        $sql = "SELECT am.* FROM {analytics_models} am";
 
-        if ($enabled || $trained) {
+        if ($enabled || $trained || $predictioncontext) {
             $conditions = [];
             if ($enabled) {
                 $conditions[] = 'am.enabled = :enabled';
@@ -102,6 +96,10 @@ class manager {
                 $conditions[] = 'am.trained = :trained';
                 $params['trained'] = 1;
             }
+            if ($predictioncontext) {
+                $conditions[] = "EXISTS (SELECT 'x' FROM {analytics_predictions} ap WHERE ap.modelid = am.id AND ap.contextid = :contextid)";
+                $params['contextid'] = $predictioncontext->id;
+            }
             $sql .= ' WHERE ' . implode(' AND ', $conditions);
         }
         $sql .= ' ORDER BY am.enabled DESC, am.timemodified DESC';
index 79352cb..f1c2708 100644 (file)
@@ -395,20 +395,30 @@ class model {
      * Updates the model.
      *
      * @param int|bool $enabled
-     * @param \core_analytics\local\indicator\base[] $indicators
-     * @param string $timesplittingid
+     * @param \core_analytics\local\indicator\base[]|false $indicators False to respect current indicators
+     * @param string|false $timesplittingid False to respect current time splitting method
      * @return void
      */
-    public function update($enabled, $indicators, $timesplittingid = '') {
+    public function update($enabled, $indicators = false, $timesplittingid = '') {
         global $USER, $DB;
 
         \core_analytics\manager::check_can_manage_models();
 
         $now = time();
 
-        $indicatorclasses = self::indicator_classes($indicators);
+        if ($indicators !== false) {
+            $indicatorclasses = self::indicator_classes($indicators);
+            $indicatorsstr = json_encode($indicatorclasses);
+        } else {
+            // Respect current value.
+            $indicatorsstr = $this->model->indicators;
+        }
+
+        if ($timesplittingid === false) {
+            // Respect current value.
+            $timesplittingid = $this->model->timesplitting;
+        }
 
-        $indicatorsstr = json_encode($indicatorclasses);
         if ($this->model->timesplitting !== $timesplittingid ||
                 $this->model->indicators !== $indicatorsstr) {
             // We update the version of the model so different time splittings are not mixed up.
@@ -900,7 +910,7 @@ class model {
     /**
      * Enabled the model using the provided time splitting method.
      *
-     * @param string $timesplittingid
+     * @param string|false $timesplittingid False to respect the current time splitting method.
      * @return void
      */
     public function enable($timesplittingid = false) {
@@ -1003,7 +1013,7 @@ class model {
      */
     public function any_prediction_obtained() {
         global $DB;
-        return $DB->record_exists('analytics_predict_ranges',
+        return $DB->record_exists('analytics_predict_samples',
             array('modelid' => $this->model->id, 'timesplitting' => $this->model->timesplitting));
     }
 
@@ -1223,6 +1233,19 @@ class model {
             $limitfrom, $limitnum);
     }
 
+    /**
+     * Merges all training data files into one and returns it.
+     *
+     * @return \stored_file|false
+     */
+    public function get_training_data() {
+
+        \core_analytics\manager::check_can_manage_models();
+
+        $timesplittingid = $this->get_time_splitting()->get_id();
+        return \core_analytics\dataset_manager::export_training_data($this->get_id(), $timesplittingid);
+    }
+
     /**
      * Flag the provided file as used for training or prediction.
      *
@@ -1307,8 +1330,8 @@ class model {
     private function clear_model() {
         global $DB;
 
-        $DB->delete_records('analytics_predict_ranges', array('modelid' => $this->model->id));
         $DB->delete_records('analytics_predictions', array('modelid' => $this->model->id));
+        $DB->delete_records('analytics_predict_samples', array('modelid' => $this->model->id));
         $DB->delete_records('analytics_train_samples', array('modelid' => $this->model->id));
         $DB->delete_records('analytics_used_files', array('modelid' => $this->model->id));
 
index 8e9a634..d616d4b 100644 (file)
@@ -83,7 +83,7 @@ class site implements \core_analytics\analysable {
         if ($events) {
             // There should be just 1 event.
             $event = reset($events);
-            $this->start = $event->timecreated;
+            $this->start = intval($event->timecreated);
         } else {
             $this->start = 0;
         }
@@ -111,7 +111,7 @@ class site implements \core_analytics\analysable {
         if ($events) {
             // There should be just 1 event.
             $event = reset($events);
-            $this->end = $event->timecreated;
+            $this->end = intval($event->timecreated);
         } else {
             $this->end = time();
         }
index a0d278b..c778a5c 100644 (file)
@@ -31,6 +31,8 @@ require_once(__DIR__ . '/fixtures/test_indicator_random.php');
 require_once(__DIR__ . '/fixtures/test_target_shortname.php');
 require_once(__DIR__ . '/fixtures/test_static_target_shortname.php');
 
+require_once(__DIR__ . '/../../course/lib.php');
+
 /**
  * Unit tests for evaluation, training and prediction.
  *
@@ -81,7 +83,7 @@ class core_analytics_prediction_testcase extends advanced_testcase {
         }
 
         // 1 range for each analysable.
-        $predictedranges = $DB->get_records('analytics_predict_ranges', array('modelid' => $model->get_id()));
+        $predictedranges = $DB->get_records('analytics_predict_samples', array('modelid' => $model->get_id()));
         $this->assertCount(2, $predictedranges);
         $this->assertEquals(1, $DB->count_records('analytics_used_files',
             array('modelid' => $model->get_id(), 'action' => 'predicted')));
@@ -91,7 +93,7 @@ class core_analytics_prediction_testcase extends advanced_testcase {
 
         // No new generated files nor records as there are no new courses available.
         $model->predict();
-        $predictedranges = $DB->get_records('analytics_predict_ranges', array('modelid' => $model->get_id()));
+        $predictedranges = $DB->get_records('analytics_predict_samples', array('modelid' => $model->get_id()));
         $this->assertCount(2, $predictedranges);
         $this->assertEquals(1, $DB->count_records('analytics_used_files',
             array('modelid' => $model->get_id(), 'action' => 'predicted')));
@@ -104,11 +106,11 @@ class core_analytics_prediction_testcase extends advanced_testcase {
      *
      * @dataProvider provider_ml_training_and_prediction
      * @param string $timesplittingid
-     * @param int $npredictedranges
+     * @param int $predictedrangeindex
      * @param string $predictionsprocessorclass
      * @return void
      */
-    public function test_ml_training_and_prediction($timesplittingid, $npredictedranges, $predictionsprocessorclass) {
+    public function test_ml_training_and_prediction($timesplittingid, $predictedrangeindex, $predictionsprocessorclass) {
         global $DB;
 
         $this->resetAfterTest(true);
@@ -176,22 +178,75 @@ class core_analytics_prediction_testcase extends advanced_testcase {
             $this->assertEquals($correct[$sampleid], $predictiondata->prediction);
         }
 
-        // 2 ranges will be predicted.
-        $predictedranges = $DB->get_records('analytics_predict_ranges', array('modelid' => $model->get_id()));
-        $this->assertCount($npredictedranges, $predictedranges);
+        // 1 range will be predicted.
+        $predictedranges = $DB->get_records('analytics_predict_samples', array('modelid' => $model->get_id()));
+        $this->assertCount(1, $predictedranges);
+        foreach ($predictedranges as $predictedrange) {
+            $this->assertEquals($predictedrangeindex, $predictedrange->rangeindex);
+            $sampleids = json_decode($predictedrange->sampleids, true);
+            $this->assertCount(2, $sampleids);
+            $this->assertContains($course1->id, $sampleids);
+            $this->assertContains($course2->id, $sampleids);
+        }
         $this->assertEquals(1, $DB->count_records('analytics_used_files',
             array('modelid' => $model->get_id(), 'action' => 'predicted')));
-        // 2 predictions for each range.
-        $this->assertEquals(2 * $npredictedranges, $DB->count_records('analytics_predictions',
+        // 2 predictions.
+        $this->assertEquals(2, $DB->count_records('analytics_predictions',
             array('modelid' => $model->get_id())));
 
         // No new generated files nor records as there are no new courses available.
         $model->predict();
-        $predictedranges = $DB->get_records('analytics_predict_ranges', array('modelid' => $model->get_id()));
-        $this->assertCount($npredictedranges, $predictedranges);
+        $predictedranges = $DB->get_records('analytics_predict_samples', array('modelid' => $model->get_id()));
+        $this->assertCount(1, $predictedranges);
+        foreach ($predictedranges as $predictedrange) {
+            $this->assertEquals($predictedrangeindex, $predictedrange->rangeindex);
+        }
         $this->assertEquals(1, $DB->count_records('analytics_used_files',
             array('modelid' => $model->get_id(), 'action' => 'predicted')));
-        $this->assertEquals(2 * $npredictedranges, $DB->count_records('analytics_predictions',
+        $this->assertEquals(2, $DB->count_records('analytics_predictions',
+            array('modelid' => $model->get_id())));
+
+        // New samples that can be used for prediction.
+        $courseparams = $params + array('shortname' => 'cccccc', 'fullname' => 'cccccc', 'visible' => 0);
+        $course3 = $this->getDataGenerator()->create_course($courseparams);
+        $courseparams = $params + array('shortname' => 'dddddd', 'fullname' => 'dddddd', 'visible' => 0);
+        $course4 = $this->getDataGenerator()->create_course($courseparams);
+
+        $result = $model->predict();
+
+        $predictedranges = $DB->get_records('analytics_predict_samples', array('modelid' => $model->get_id()));
+        $this->assertCount(1, $predictedranges);
+        foreach ($predictedranges as $predictedrange) {
+            $this->assertEquals($predictedrangeindex, $predictedrange->rangeindex);
+            $sampleids = json_decode($predictedrange->sampleids, true);
+            $this->assertCount(4, $sampleids);
+            $this->assertContains($course1->id, $sampleids);
+            $this->assertContains($course2->id, $sampleids);
+            $this->assertContains($course3->id, $sampleids);
+            $this->assertContains($course4->id, $sampleids);
+        }
+        $this->assertEquals(2, $DB->count_records('analytics_used_files',
+            array('modelid' => $model->get_id(), 'action' => 'predicted')));
+        $this->assertEquals(4, $DB->count_records('analytics_predictions',
+            array('modelid' => $model->get_id())));
+
+        // New visible course (for training).
+        $course5 = $this->getDataGenerator()->create_course(array('shortname' => 'aaa', 'fullname' => 'aa'));
+        $course6 = $this->getDataGenerator()->create_course();
+        $result = $model->train();
+        $this->assertEquals(2, $DB->count_records('analytics_used_files',
+            array('modelid' => $model->get_id(), 'action' => 'trained')));
+
+        // Update one of the courses to not visible, it should be used again for prediction.
+        $course5->visible = 0;
+        update_course($course5);
+
+        $model->predict();
+        $this->assertEquals(1, $DB->count_records('analytics_predict_samples',
+            array('modelid' => $model->get_id())));
+        $this->assertEquals(2, $DB->count_records('analytics_used_files',
+            array('modelid' => $model->get_id(), 'action' => 'predicted')));
+        $this->assertEquals(4, $DB->count_records('analytics_predictions',
             array('modelid' => $model->get_id())));
 
         set_config('enabled_stores', '', 'tool_log');
@@ -205,8 +260,8 @@ class core_analytics_prediction_testcase extends advanced_testcase {
      */
     public function provider_ml_training_and_prediction() {
         $cases = array(
-            'no_splitting' => array('\core\analytics\time_splitting\no_splitting', 1),
-            'quarters' => array('\core\analytics\time_splitting\quarters', 4)
+            'no_splitting' => array('\core\analytics\time_splitting\no_splitting', 0),
+            'quarters' => array('\core\analytics\time_splitting\quarters', 3)
         );
 
         // We need to test all system prediction processors.
index 7f59282..6cf1f28 100644 (file)
@@ -79,6 +79,7 @@ if (!defined('LDAP_OPT_DIAGNOSTIC_MESSAGE')) {
 require_once($CFG->libdir.'/authlib.php');
 require_once($CFG->libdir.'/ldaplib.php');
 require_once($CFG->dirroot.'/user/lib.php');
+require_once($CFG->dirroot.'/auth/ldap/locallib.php');
 
 /**
  * LDAP authentication plugin.
@@ -865,14 +866,6 @@ class auth_plugin_ldap extends auth_plugin_base {
             if (!empty($users)) {
                 print_string('userentriestoupdate', 'auth_ldap', count($users));
 
-                $sitecontext = context_system::instance();
-                if (!empty($this->config->creators) and !empty($this->config->memberattribute)
-                  and $roles = get_archetype_roles('coursecreator')) {
-                    $creatorrole = array_shift($roles);      // We can only use one, let's use the first one
-                } else {
-                    $creatorrole = false;
-                }
-
                 $transaction = $DB->start_delegated_transaction();
                 $xcount = 0;
                 $maxxcount = 100;
@@ -885,14 +878,8 @@ class auth_plugin_ldap extends auth_plugin_base {
                     echo "\n";
                     $xcount++;
 
-                    // Update course creators if needed
-                    if ($creatorrole !== false) {
-                        if ($this->iscreator($user->username)) {
-                            role_assign($creatorrole->id, $user->id, $sitecontext->id, $this->roleauth);
-                        } else {
-                            role_unassign($creatorrole->id, $user->id, $sitecontext->id, $this->roleauth);
-                        }
-                    }
+                    // Update system roles, if needed.
+                    $this->sync_roles($user);
                 }
                 $transaction->allow_commit();
                 unset($users); // free mem
@@ -914,14 +901,6 @@ class auth_plugin_ldap extends auth_plugin_base {
         if (!empty($add_users)) {
             print_string('userentriestoadd', 'auth_ldap', count($add_users));
 
-            $sitecontext = context_system::instance();
-            if (!empty($this->config->creators) and !empty($this->config->memberattribute)
-              and $roles = get_archetype_roles('coursecreator')) {
-                $creatorrole = array_shift($roles);      // We can only use one, let's use the first one
-            } else {
-                $creatorrole = false;
-            }
-
             $transaction = $DB->start_delegated_transaction();
             foreach ($add_users as $user) {
                 $user = $this->get_userinfo_asobj($user->username);
@@ -949,16 +928,14 @@ class auth_plugin_ldap extends auth_plugin_base {
 
                 $id = user_create_user($user, false);
                 echo "\t"; print_string('auth_dbinsertuser', 'auth_db', array('name'=>$user->username, 'id'=>$id)); echo "\n";
-                $euser = $DB->get_record('user', array('id' => $id));
+                $user = $DB->get_record('user', array('id' => $id));
 
                 if (!empty($this->config->forcechangepassword)) {
                     set_user_preference('auth_forcepasswordchange', 1, $id);
                 }
 
-                // Add course creators if needed
-                if ($creatorrole !== false and $this->iscreator($user->username)) {
-                    role_assign($creatorrole->id, $id, $sitecontext->id, $this->roleauth);
-                }
+                // Add roles if needed.
+                $this->sync_roles($user);
 
             }
             $transaction->allow_commit();
@@ -1102,8 +1079,12 @@ class auth_plugin_ldap extends auth_plugin_base {
      *
      * @param mixed $username    username (without system magic quotes)
      * @return mixed result      null if course creators is not configured, boolean otherwise.
+     *
+     * @deprecated since Moodle 3.4 MDL-30634 - please do not use this function any more.
      */
     function iscreator($username) {
+        debugging('iscreator() is deprecated. Please use auth_plugin_ldap::is_role() instead.', DEBUG_DEVELOPER);
+
         if (empty($this->config->creators) or empty($this->config->memberattribute)) {
             return null;
         }
@@ -1128,6 +1109,40 @@ class auth_plugin_ldap extends auth_plugin_base {
         return $creator;
     }
 
+    /**
+     * Check if user has LDAP group membership.
+     *
+     * Returns true if user should be assigned role.
+     *
+     * @param mixed $username username (without system magic quotes).
+     * @param array $role Array of role's shortname, localname, and settingname for the config value.
+     * @return mixed result null if role/LDAP context is not configured, boolean otherwise.
+     */
+    private function is_role($username, $role) {
+        if (empty($this->config->{$role['settingname']}) or empty($this->config->memberattribute)) {
+            return null;
+        }
+
+        $extusername = core_text::convert($username, 'utf-8', $this->config->ldapencoding);
+
+        $ldapconnection = $this->ldap_connect();
+
+        if ($this->config->memberattribute_isdn) {
+            if (!($userid = $this->ldap_find_userdn($ldapconnection, $extusername))) {
+                return false;
+            }
+        } else {
+            $userid = $extusername;
+        }
+
+        $groupdns = explode(';', $this->config->{$role['settingname']});
+        $isrole = ldap_isgroupmember($ldapconnection, $userid, $groupdns, $this->config->memberattribute);
+
+        $this->ldap_close();
+
+        return $isrole;
+    }
+
     /**
      * Called when the user record is updated.
      *
@@ -1792,25 +1807,29 @@ class auth_plugin_ldap extends auth_plugin_base {
     }
 
     /**
-     * Sync roles for this user
+     * Sync roles for this user.
      *
-     * @param $user object user object (without system magic quotes)
+     * @param object $user The user to sync (without system magic quotes).
      */
     function sync_roles($user) {
-        $iscreator = $this->iscreator($user->username);
-        if ($iscreator === null) {
-            return; // Nothing to sync - creators not configured
-        }
+        global $DB;
 
-        if ($roles = get_archetype_roles('coursecreator')) {
-            $creatorrole = array_shift($roles);      // We can only use one, let's use the first one
-            $systemcontext = context_system::instance();
+        $roles = get_ldap_assignable_role_names(2); // Admin user.
 
-            if ($iscreator) { // Following calls will not create duplicates
-                role_assign($creatorrole->id, $user->id, $systemcontext->id, $this->roleauth);
+        foreach ($roles as $role) {
+            $isrole = $this->is_role($user->username, $role);
+            if ($isrole === null) {
+                continue; // Nothing to sync - role/LDAP contexts not configured.
+            }
+
+            // Sync user.
+            $systemcontext = context_system::instance();
+            if ($isrole) {
+                // Following calls will not create duplicates.
+                role_assign($role['id'], $user->id, $systemcontext->id, $this->roleauth);
             } else {
-                // Unassign only if previously assigned by this plugin!
-                role_unassign($creatorrole->id, $user->id, $systemcontext->id, $this->roleauth);
+                // Unassign only if previously assigned by this plugin.
+                role_unassign($role['id'], $user->id, $systemcontext->id, $this->roleauth);
             }
         }
     }
diff --git a/auth/ldap/classes/task/sync_roles.php b/auth/ldap/classes/task/sync_roles.php
new file mode 100644 (file)
index 0000000..37ea23c
--- /dev/null
@@ -0,0 +1,61 @@
+<?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/>.
+
+/**
+ * A scheduled task for LDAP roles sync.
+ *
+ * @package    auth_ldap
+ * @author     David Balch <david.balch@conted.ox.ac.uk>
+ * @copyright  2017 The Chancellor Masters and Scholars of the University of Oxford {@link http://www.tall.ox.ac.uk}
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+namespace auth_ldap\task;
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * A scheduled task class for LDAP roles sync.
+ *
+ * @author     David Balch <david.balch@conted.ox.ac.uk>
+ * @copyright  2017 The Chancellor Masters and Scholars of the University of Oxford {@link http://www.tall.ox.ac.uk}
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class sync_roles extends \core\task\scheduled_task {
+
+    /**
+     * Get a descriptive name for this task (shown to admins).
+     *
+     * @return string
+     */
+    public function get_name() {
+        return get_string('syncroles', 'auth_ldap');
+    }
+
+    /**
+     * Synchronise role assignments from LDAP.
+     */
+    public function execute() {
+        global $DB;
+        if (is_enabled_auth('ldap')) {
+            $auth = get_auth_plugin('ldap');
+            $users = $DB->get_records('user', array('auth' => 'ldap'));
+            foreach ($users as $user) {
+                $auth->sync_roles($user);
+            }
+        }
+    }
+
+}
index ba8c63c..0fe49cd 100644 (file)
 defined('MOODLE_INTERNAL') || die();
 
 $tasks = array(
+    array(
+        'classname' => 'auth_ldap\task\sync_roles',
+        'blocking' => 0,
+        'minute' => '0',
+        'hour' => '0',
+        'day' => '*',
+        'month' => '*',
+        'dayofweek' => '*',
+        'disabled' => 1
+    ),
     array(
         'classname' => 'auth_ldap\task\sync_task',
         'blocking' => 0,
index 3e1c58f..a6f7ced 100644 (file)
@@ -48,5 +48,23 @@ function xmldb_auth_ldap_upgrade($oldversion) {
     // Automatically generated Moodle v3.3.0 release upgrade line.
     // Put any upgrade step following this.
 
+    if ($oldversion < 2017080100) {
+        // The "auth_ldap/coursecreators" setting was replaced with "auth_ldap/coursecreatorcontext" (created
+        // dynamically from system-assignable roles) - so migrate any existing value to the first new slot.
+        if ($ldapcontext = get_config('auth_ldap', 'creators')) {
+            // Get info about the role that the old coursecreators setting would apply.
+            $creatorrole = get_archetype_roles('coursecreator');
+            $creatorrole = array_shift($creatorrole); // We can only use one, let's use the first.
+
+            // Create new setting.
+            set_config($creatorrole->shortname . 'context', $ldapcontext, 'auth_ldap');
+
+            // Delete old setting.
+            set_config('creators', null, 'auth_ldap');
+
+            upgrade_plugin_savepoint(true, 2017080100, 'auth', 'ldap');
+        }
+    }
+
     return true;
 }
index 8efc99a..7a693b5 100644 (file)
@@ -36,8 +36,6 @@ $string['auth_ldap_contexts_key'] = 'Contexts';
 $string['auth_ldap_create_context'] = 'If you enable user creation with email confirmation, specify the context where users are created. This context should be different from other users to prevent security issues. You don\'t need to add this context to ldap_context-variable, Moodle will search for users from this context automatically.<br /><b>Note!</b> You have to modify the method user_create() in file auth/ldap/auth.php to make user creation work';
 $string['auth_ldap_create_context_key'] = 'Context for new users';
 $string['auth_ldap_create_error'] = 'Error creating user in LDAP.';
-$string['auth_ldap_creators'] = 'List of groups or contexts whose members are allowed to create new courses. Separate multiple groups with \';\'. Usually something like \'cn=teachers,ou=staff,o=myorg\'';
-$string['auth_ldap_creators_key'] = 'Creators';
 $string['auth_ldapdescription'] = 'This method provides authentication against an external LDAP server.
                                   If the given username and password are valid, Moodle creates a new user
                                   entry in its database. This module can read user attributes from LDAP and prefill
@@ -80,6 +78,8 @@ $string['auth_ldap_passtype_key'] = 'Password format';
 $string['auth_ldap_passwdexpire_settings'] = 'LDAP password expiration settings';
 $string['auth_ldap_preventpassindb'] = 'Select yes to prevent passwords from being stored in Moodle\'s DB.';
 $string['auth_ldap_preventpassindb_key'] = 'Prevent password caching';
+$string['auth_ldap_rolecontext'] = '{$a->localname} context';
+$string['auth_ldap_rolecontext_help'] = 'LDAP context used to select for <i>{$a->localname}</i> mapping. Separate multiple groups with \';\'. Usually something like "cn={$a->shortname},ou=staff,o=myorg".';
 $string['auth_ldap_search_sub'] = 'Search users from subcontexts.';
 $string['auth_ldap_search_sub_key'] = 'Search subcontexts';
 $string['auth_ldap_server_settings'] = 'LDAP server settings';
@@ -141,7 +141,9 @@ $string['pluginname'] = 'LDAP server';
 $string['pluginnotenabled'] = 'Plugin not enabled!';
 $string['renamingnotallowed'] = 'User renaming not allowed in LDAP';
 $string['rootdseerror'] = 'Error querying rootDSE for Active Directory';
+$string['syncroles'] = 'Synchronise system roles from LDAP';
 $string['synctask'] = 'LDAP users sync job';
+$string['systemrolemapping'] = 'System role mapping';
 $string['start_tls'] = 'Use regular LDAP service (port 389) with TLS encryption';
 $string['start_tls_key'] = 'Use TLS';
 $string['updateremfail'] = 'Error updating LDAP record. Error code: {$a->errno}; Error string: {$a->errstring}<br/>Key ({$a->key}) - old moodle value: \'{$a->ouvalue}\' new value: \'{$a->nuvalue}\'';
@@ -158,3 +160,7 @@ $string['userentriestorevive'] = "User entries to be revived: {\$a}\n";
 $string['userentriestoupdate'] = "User entries to be updated: {\$a}\n";
 $string['usernotfound'] = 'User not found in LDAP';
 $string['useracctctrlerror'] = 'Error getting userAccountControl for {$a}';
+
+// Deprecated since Moodle 3.4.
+$string['auth_ldap_creators'] = 'List of groups or contexts whose members are allowed to create new courses. Separate multiple groups with \';\'. Usually something like \'cn=teachers,ou=staff,o=myorg\'';
+$string['auth_ldap_creators_key'] = 'Creators';
diff --git a/auth/ldap/lang/en/deprecated.txt b/auth/ldap/lang/en/deprecated.txt
new file mode 100644 (file)
index 0000000..fa73d1e
--- /dev/null
@@ -0,0 +1,2 @@
+auth_ldap_creators,auth_ldap
+auth_ldap_creators_key,auth_ldap
diff --git a/auth/ldap/locallib.php b/auth/ldap/locallib.php
new file mode 100644 (file)
index 0000000..5688064
--- /dev/null
@@ -0,0 +1,53 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Internal library of functions for module auth_ldap
+ *
+ * @package    auth_ldap
+ * @author     David Balch <david.balch@conted.ox.ac.uk>
+ * @copyright  2017 The Chancellor Masters and Scholars of the University of Oxford {@link http://www.tall.ox.ac.uk/}
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Get a list of system roles assignable by the current or a specified user, including their localised names.
+ *
+ * @param integer|object $user A user id or object. By default (null) checks the permissions of the current user.
+ * @return array $roles, each role as an array with id, shortname, localname, and settingname for the config value.
+ */
+function get_ldap_assignable_role_names($user = null) {
+    $roles = array();
+
+    if ($assignableroles = get_assignable_roles(context_system::instance(), ROLENAME_SHORT, false, $user)) {
+        $systemroles = role_fix_names(get_all_roles(), context_system::instance(), ROLENAME_ORIGINAL);
+        foreach ($assignableroles as $shortname) {
+            foreach ($systemroles as $systemrole) {
+                if ($systemrole->shortname == $shortname) {
+                    $roles[] = array('id' => $systemrole->id,
+                                     'shortname' => $shortname,
+                                     'localname' => $systemrole->localname,
+                                     'settingname' => $shortname . 'context');
+                    break;
+                }
+            }
+        }
+    }
+
+    return $roles;
+}
index 5f62569..af1ddf7 100644 (file)
@@ -226,14 +226,17 @@ if ($ADMIN->fulltree) {
                 get_string('auth_ldap_create_context_key', 'auth_ldap'),
                 get_string('auth_ldap_create_context', 'auth_ldap'), '', PARAM_RAW_TRIMMED));
 
-        // Course Creators Header.
-        $settings->add(new admin_setting_heading('auth_ldap/coursecreators',
-                new lang_string('coursecreators'), ''));
-
-        // Course creators field mapping.
-        $settings->add(new admin_setting_configtext('auth_ldap/creators',
-                get_string('auth_ldap_creators_key', 'auth_ldap'),
-                get_string('auth_ldap_creators', 'auth_ldap'), '', PARAM_RAW_TRIMMED));
+        // System roles mapping header.
+        $settings->add(new admin_setting_heading('auth_ldap/systemrolemapping',
+                                        new lang_string('systemrolemapping', 'auth_ldap'), ''));
+
+        // Create system role mapping field for each assignable system role.
+        $roles = get_ldap_assignable_role_names();
+        foreach ($roles as $role) {
+            $settings->add(new admin_setting_configtext('auth_ldap/' . $role['settingname'],
+                    get_string('auth_ldap_rolecontext', 'auth_ldap', $role),
+                    get_string('auth_ldap_rolecontext_help', 'auth_ldap', $role), '', PARAM_RAW_TRIMMED));
+        }
 
         // User Account Sync.
         $settings->add(new admin_setting_heading('auth_ldap/syncusers',
index d50f67d..f746583 100644 (file)
@@ -110,7 +110,7 @@ class auth_ldap_plugin_testcase extends advanced_testcase {
         set_config('user_attribute', 'cn', 'auth_ldap');
         set_config('memberattribute', 'memberuid', 'auth_ldap');
         set_config('memberattribute_isdn', 0, 'auth_ldap');
-        set_config('creators', 'cn=creators,'.$topdn, 'auth_ldap');
+        set_config('coursecreatorcontext', 'cn=creators,'.$topdn, 'auth_ldap');
         set_config('removeuser', AUTH_REMOVEUSER_KEEP, 'auth_ldap');
 
         set_config('field_map_email', 'mail', 'auth_ldap');
index d5cb801..239e8ef 100644 (file)
@@ -1,5 +1,10 @@
 This files describes API changes in the auth_ldap code.
 
+=== 3.4 ===
+
+* The "auth_ldap/coursecreators" setting was replaced with dynamically generated "auth_ldap/<role>context" settings,
+  migrating any existing value to a new setting in this style.
+
 === 3.3 ===
 
 * The config.html file was migrated to use the admin settings API.
index 5e96509..93ce0e7 100644 (file)
@@ -25,6 +25,6 @@
 
 defined('MOODLE_INTERNAL') || die();
 
-$plugin->version   = 2017051500;        // The current plugin version (Date: YYYYMMDDXX)
+$plugin->version   = 2017080100;        // The current plugin version (Date: YYYYMMDDXX)
 $plugin->requires  = 2017050500;        // Requires this Moodle version
 $plugin->component = 'auth_ldap';       // Full name of the plugin (used for diagnostics)
index 8b425d7..7427328 100644 (file)
@@ -65,8 +65,9 @@ class frontend extends \core_availability\frontend {
                 // Add each course-module if it has completion turned on and is not
                 // the one currently being edited.
                 if ($othercm->completion && (empty($cm) || $cm->id != $id) && !$othercm->deletioninprogress) {
-                    $cms[] = (object)array('id' => $id, 'name' =>
-                            format_string($othercm->name, true, array('context' => $context)));
+                    $cms[] = (object)array('id' => $id,
+                        'name' => format_string($othercm->name, true, array('context' => $context)),
+                        'completiongradeitemnumber' => $othercm->completiongradeitemnumber);
                 }
             }
 
index aa41e64..938cbab 100644 (file)
@@ -24,6 +24,7 @@
 
 $string['description'] = 'Require students to complete (or not complete) another activity.';
 $string['error_selectcmid'] = 'You must select an activity for the completion condition.';
+$string['error_selectcmidpassfail'] = 'You must select an activity with "Require grade" completion condition set.';
 $string['label_cm'] = 'Activity or resource';
 $string['label_completion'] = 'Required completion status';
 $string['missing'] = '(Missing activity)';
index 4885548..5212364 100644 (file)
Binary files a/availability/condition/completion/yui/build/moodle-availability_completion-form/moodle-availability_completion-form-debug.js and b/availability/condition/completion/yui/build/moodle-availability_completion-form/moodle-availability_completion-form-debug.js differ
index 36efbb5..10be26c 100644 (file)
Binary files a/availability/condition/completion/yui/build/moodle-availability_completion-form/moodle-availability_completion-form-min.js and b/availability/condition/completion/yui/build/moodle-availability_completion-form/moodle-availability_completion-form-min.js differ
index 4885548..5212364 100644 (file)
Binary files a/availability/condition/completion/yui/build/moodle-availability_completion-form/moodle-availability_completion-form.js and b/availability/condition/completion/yui/build/moodle-availability_completion-form/moodle-availability_completion-form.js differ
index e7b418e..77c9cf1 100644 (file)
@@ -76,4 +76,14 @@ M.availability_completion.form.fillErrors = function(errors, node) {
     if (cmid === 0) {
         errors.push('availability_completion:error_selectcmid');
     }
+    var e = parseInt(node.one('select[name=e]').get('value'), 10);
+    if (((e === 2) || (e === 3))) {
+        this.cms.forEach(function(cm) {
+            if (cm.id === cmid) {
+                if (cm.completiongradeitemnumber === null) {
+                    errors.push('availability_completion:error_selectcmidpassfail');
+                }
+            }
+        });
+    }
 };
index a984525..546e378 100644 (file)
@@ -96,7 +96,7 @@ Feature: availability_grade
     And I click on "Add submission" "button"
     And I set the field "Online text" to "Q"
     And I click on "Save changes" "button"
-    And I follow "C1"
+    And I am on "Course 1" course homepage
 
     # None of the pages should appear (check assignment though).
     Then I should not see "P2" in the "region-main" "region"
index ce0392e..3c35227 100644 (file)
@@ -36,7 +36,7 @@ Feature: edit_availability
     And I add a "Page" to section "1"
     Then "Restrict access" "fieldset" should not exist
 
-    Given I follow "C1"
+    Given I am on "Course 1" course homepage
     When I edit the section "1"
     Then "Restrict access" "fieldset" should not exist
 
@@ -47,7 +47,7 @@ Feature: edit_availability
     And I add a "Page" to section "1"
     Then "Restrict access" "fieldset" should exist
 
-    Given I follow "C1"
+    Given I am on "Course 1" course homepage
     When I edit the section "1"
     Then "Restrict access" "fieldset" should exist
 
index b826709..dfcf545 100644 (file)
@@ -203,9 +203,7 @@ class restore_gradebook_structure_step extends restore_structure_step {
         $data->scaleid   = $this->get_mappingid('scale', $data->scaleid, NULL);
         $data->outcomeid = $this->get_mappingid('outcome', $data->outcomeid, NULL);
 
-        $data->locktime     = $this->apply_date_offset($data->locktime);
-        $data->timecreated  = $this->apply_date_offset($data->timecreated);
-        $data->timemodified = $this->apply_date_offset($data->timemodified);
+        $data->locktime = $this->apply_date_offset($data->locktime);
 
         $coursecategory = $newitemid = null;
         //course grade item should already exist so updating instead of inserting
@@ -264,10 +262,6 @@ class restore_gradebook_structure_step extends restore_structure_step {
         if (!empty($data->userid)) {
             $data->usermodified = $this->get_mappingid('user', $data->usermodified, null);
             $data->locktime     = $this->apply_date_offset($data->locktime);
-            // TODO: Ask, all the rest of locktime/exported... work with time... to be rolled?
-            $data->overridden = $this->apply_date_offset($data->overridden);
-            $data->timecreated  = $this->apply_date_offset($data->timecreated);
-            $data->timemodified = $this->apply_date_offset($data->timemodified);
 
             $gradeexists = $DB->record_exists('grade_grades', array('userid' => $data->userid, 'itemid' => $data->itemid));
             if ($gradeexists) {
@@ -292,9 +286,6 @@ class restore_gradebook_structure_step extends restore_structure_step {
         $data->course = $this->get_courseid();
         $data->courseid = $data->course;
 
-        $data->timecreated  = $this->apply_date_offset($data->timecreated);
-        $data->timemodified = $this->apply_date_offset($data->timemodified);
-
         $newitemid = null;
         //no parent means a course level grade category. That may have been created when the course was created
         if(empty($data->parent)) {
@@ -1576,7 +1567,7 @@ class restore_section_structure_step extends restore_structure_step {
         $section = new stdclass();
         $section->course  = $this->get_courseid();
         $section->section = $data->number;
-        $section->timemodified = isset($data->timemodified) ? $this->apply_date_offset($data->timemodified) : 0;
+        $section->timemodified = $data->timemodified ?? 0;
         // Section doesn't exist, create it with all the info from backup
         if (!$secrec = $DB->get_record('course_sections', ['course' => $this->get_courseid(), 'section' => $data->number])) {
             $section->name = $data->name;
@@ -2534,8 +2525,8 @@ class restore_badges_structure_step extends restore_structure_step {
         $params = array(
                 'name'           => $data->name,
                 'description'    => $data->description,
-                'timecreated'    => $this->apply_date_offset($data->timecreated),
-                'timemodified'   => $this->apply_date_offset($data->timemodified),
+                'timecreated'    => $data->timecreated,
+                'timemodified'   => $data->timemodified,
                 'usercreated'    => $data->usercreated,
                 'usermodified'   => $data->usermodified,
                 'issuername'     => $data->issuername,
@@ -2550,7 +2541,7 @@ class restore_badges_structure_step extends restore_structure_step {
                 'attachment'     => $data->attachment,
                 'notification'   => $data->notification,
                 'status'         => BADGE_STATUS_INACTIVE,
-                'nextcron'       => $this->apply_date_offset($data->nextcron)
+                'nextcron'       => $data->nextcron
         );
 
         $newid = $DB->insert_record('badge', $params);
@@ -2727,7 +2718,7 @@ class restore_calendarevents_structure_step extends restore_structure_step {
                 'visible'        => $data->visible,
                 'uuid'           => $data->uuid,
                 'sequence'       => $data->sequence,
-                'timemodified'   => $this->apply_date_offset($data->timemodified),
+                'timemodified'   => $data->timemodified,
                 'priority'       => isset($data->priority) ? $data->priority : null);
         if ($this->name == 'activity_calendar') {
             $params['instance'] = $this->task->get_activityid();
@@ -2959,7 +2950,7 @@ class restore_course_completion_structure_step extends restore_structure_step {
                 'userid' => $data->userid,
                 'course' => $data->course,
                 'criteriaid' => $data->criteriaid,
-                'timecompleted' => $this->apply_date_offset($data->timecompleted)
+                'timecompleted' => $data->timecompleted
             );
             if (isset($data->gradefinal)) {
                 $params['gradefinal'] = $data->gradefinal;
@@ -2989,9 +2980,9 @@ class restore_course_completion_structure_step extends restore_structure_step {
             $params = array(
                 'userid' => $data->userid,
                 'course' => $data->course,
-                'timeenrolled' => $this->apply_date_offset($data->timeenrolled),
-                'timestarted' => $this->apply_date_offset($data->timestarted),
-                'timecompleted' => $this->apply_date_offset($data->timecompleted),
+                'timeenrolled' => $data->timeenrolled,
+                'timestarted' => $data->timestarted,
+                'timecompleted' => $data->timecompleted,
                 'reaggregate' => $data->reaggregate
             );
 
@@ -3693,8 +3684,6 @@ class restore_activity_grades_structure_step extends restore_structure_step {
         $data->idnumber     = $idnumber;
         $data->scaleid      = $this->get_mappingid('scale', $data->scaleid);
         $data->outcomeid    = $this->get_mappingid('outcome', $data->outcomeid);
-        $data->timecreated  = $this->apply_date_offset($data->timecreated);
-        $data->timemodified = $this->apply_date_offset($data->timemodified);
 
         $gradeitem = new grade_item($data, false);
         $gradeitem->insert('restore');
@@ -3720,8 +3709,6 @@ class restore_activity_grades_structure_step extends restore_structure_step {
         if (!empty($data->userid)) {
             $data->usermodified = $this->get_mappingid('user', $data->usermodified, null);
             $data->rawscaleid = $this->get_mappingid('scale', $data->rawscaleid);
-            // TODO: Ask, all the rest of locktime/exported... work with time... to be rolled?
-            $data->overridden = $this->apply_date_offset($data->overridden);
 
             $grade = new grade_grade($data, false);
             $grade->insert('restore');
@@ -4288,7 +4275,6 @@ class restore_userscompletion_structure_step extends restore_structure_step {
 
         $data->coursemoduleid = $this->task->get_moduleid();
         $data->userid = $this->get_mappingid('user', $data->userid);
-        $data->timemodified = $this->apply_date_offset($data->timemodified);
 
         // Find the existing record
         $existing = $DB->get_record('course_modules_completion', array(
@@ -5204,7 +5190,6 @@ abstract class restore_questions_activity_structure_step extends restore_activit
         if (!property_exists($data, 'variant')) {
             $data->variant = 1;
         }
-        $data->timemodified    = $this->apply_date_offset($data->timemodified);
 
         if (!property_exists($data, 'maxfraction')) {
             $data->maxfraction = 1;
@@ -5239,7 +5224,6 @@ abstract class restore_questions_activity_structure_step extends restore_activit
         unset($data->response);
 
         $data->questionattemptid = $this->get_new_parentid($nameprefix . 'question_attempt');
-        $data->timecreated = $this->apply_date_offset($data->timecreated);
         $data->userid      = $this->get_mappingid('user', $data->userid);
 
         // Everything ready, insert and create mapping (needed by question_sessions)
diff --git a/backup/moodle2/tests/restore_stepslib_date_test.php b/backup/moodle2/tests/restore_stepslib_date_test.php
new file mode 100644 (file)
index 0000000..1bcce5f
--- /dev/null
@@ -0,0 +1,413 @@
+<?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/>.
+
+/**
+ * Restore date tests.
+ *
+ * @package    core_backup
+ * @copyright  2017 Adrian Greeve <adrian@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+global $CFG;
+require_once($CFG->libdir . "/phpunit/classes/restore_date_testcase.php");
+require_once($CFG->libdir . "/badgeslib.php");
+require_once($CFG->dirroot . '/mod/assign/tests/base_test.php');
+
+/**
+ * Restore date tests.
+ *
+ * @package    core_backup
+ * @copyright  2017 Adrian Greeve <adrian@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class restore_stepslib_date_testcase extends restore_date_testcase {
+
+    /**
+     * Restoring a manual grade item does not result in the timecreated or
+     * timemodified dates being changed.
+     */
+    public function test_grade_item_date_restore() {
+
+        $course = $this->getDataGenerator()->create_course(['startdate' => time()]);
+
+        $params = new stdClass();
+        $params->courseid = $course->id;
+        $params->fullname = 'unittestgradecalccategory';
+        $params->aggregation = GRADE_AGGREGATE_MEAN;
+        $params->aggregateonlygraded = 0;
+        $gradecategory = new grade_category($params, false);
+        $gradecategory->insert();
+
+        $gradecategory->load_grade_item();
+
+        $gradeitems = new grade_item();
+        $gradeitems->courseid = $course->id;
+        $gradeitems->categoryid = $gradecategory->id;
+        $gradeitems->itemname = 'manual grade_item';
+        $gradeitems->itemtype = 'manual';
+        $gradeitems->itemnumber = 0;
+        $gradeitems->needsupdate = false;
+        $gradeitems->gradetype = GRADE_TYPE_VALUE;
+        $gradeitems->grademin = 0;
+        $gradeitems->grademax = 10;
+        $gradeitems->iteminfo = 'Manual grade item used for unit testing';
+        $gradeitems->timecreated = time();
+        $gradeitems->timemodified = time();
+
+        $gradeitems->aggregationcoef = GRADE_AGGREGATE_SUM;
+
+        $gradeitems->insert();
+
+        $gradeitemparams = [
+            'itemtype' => 'manual',
+            'itemname' => $gradeitems->itemname,
+            'courseid' => $course->id,
+        ];
+
+        $gradeitem = grade_item::fetch($gradeitemparams);
+
+        // Do backup and restore.
+
+        $newcourseid = $this->backup_and_restore($course);
+        $newcourse = get_course($newcourseid);
+        $newgradeitemparams = [
+            'itemtype' => 'manual',
+            'itemname' => $gradeitems->itemname,
+            'courseid' => $course->id,
+        ];
+
+        $newgradeitem = grade_item::fetch($newgradeitemparams);
+        $this->assertEquals($gradeitem->timecreated, $newgradeitem->timecreated);
+        $this->assertEquals($gradeitem->timemodified, $newgradeitem->timemodified);
+    }
+
+    /**
+     * The course section timemodified date does not get rolled forward
+     * when the course is restored.
+     */
+    public function test_course_section_date_restore() {
+        global $DB;
+        // Create a course.
+        $course = $this->getDataGenerator()->create_course(['startdate' => time()]);
+        // Get the second course section.
+        $section = $DB->get_record('course_sections', ['course' => $course->id, 'section' => '1']);
+        // Do a backup and restore.
+        $newcourseid = $this->backup_and_restore($course);
+        $newcourse = get_course($newcourseid);
+
+        $newsection = $DB->get_record('course_sections', ['course' => $newcourse->id, 'section' => '1']);
+        // Compare dates.
+        $this->assertEquals($section->timemodified, $newsection->timemodified);
+    }
+
+    /**
+     * Test that the timecreated and timemodified dates are not rolled forward when restoring
+     * badge data.
+     */
+    public function test_badge_date_restore() {
+        global $DB, $USER;
+        // Create a course.
+        $course = $this->getDataGenerator()->create_course(['startdate' => time()]);
+        // Create a badge.
+        $fordb = new stdClass();
+        $fordb->id = null;
+        $fordb->name = "Test badge";
+        $fordb->description = "Testing badges";
+        $fordb->timecreated = time();
+        $fordb->timemodified = time();
+        $fordb->usercreated = $USER->id;
+        $fordb->usermodified = $USER->id;
+        $fordb->issuername = "Test issuer";
+        $fordb->issuerurl = "http://issuer-url.domain.co.nz";
+        $fordb->issuercontact = "issuer@example.com";
+        $fordb->expiredate = time();
+        $fordb->expireperiod = null;
+        $fordb->type = BADGE_TYPE_COURSE;
+        $fordb->courseid = $course->id;
+        $fordb->messagesubject = "Test message subject";
+        $fordb->message = "Test message body";
+        $fordb->attachment = 1;
+        $fordb->notification = 0;
+        $fordb->status = BADGE_STATUS_INACTIVE;
+        $fordb->nextcron = time();
+
+        $this->badgeid = $DB->insert_record('badge', $fordb, true);
+        // Do a backup and restore.
+        $newcourseid = $this->backup_and_restore($course);
+        $newcourse = get_course($newcourseid);
+
+        $badges = badges_get_badges(BADGE_TYPE_COURSE, $newcourseid);
+
+        // Compare dates.
+        $badge = array_shift($badges);
+        $this->assertEquals($fordb->timecreated, $badge->timecreated);
+        $this->assertEquals($fordb->timemodified, $badge->timemodified);
+        $this->assertEquals($fordb->nextcron, $badge->nextcron);
+        // Expire date should be moved forward.
+        $this->assertNotEquals($fordb->expiredate, $badge->expiredate);
+    }
+
+    /**
+     * Test that course calendar events timemodified field is not rolled forward
+     * when restoring the course.
+     */
+    public function test_calendarevents_date_restore() {
+        global $USER, $DB;
+        // Create course.
+        $course = $this->getDataGenerator()->create_course(['startdate' => time()]);
+        // Create calendar event.
+        $starttime = time();
+        $event = [
+                'name' => 'Start of assignment',
+                'description' => '',
+                'format' => 1,
+                'courseid' => $course->id,
+                'groupid' => 0,
+                'userid' => $USER->id,
+                'modulename' => 0,
+                'instance' => 0,
+                'eventtype' => 'course',
+                'timestart' => $starttime,
+                'timeduration' => 86400,
+                'visible' => 1
+        ];
+        $calendarevent = calendar_event::create($event, false);
+
+        // Backup and restore.
+        $newcourseid = $this->backup_and_restore($course);
+        $newcourse = get_course($newcourseid);
+
+        $newevent = $DB->get_record('event', ['courseid' => $newcourseid, 'eventtype' => 'course']);
+        // Compare dates.
+        $this->assertEquals($calendarevent->timemodified, $newevent->timemodified);
+        $this->assertNotEquals($calendarevent->timestart, $newevent->timestart);
+    }
+
+    /**
+     * Testing that the timeenrolled, timestarted, and timecompleted fields are not rolled forward / back
+     * when doing a course restore.
+     */
+    public function test_course_completion_date_restore() {
+        global $DB;
+
+        // Create course with course completion enabled.
+        $course = $this->getDataGenerator()->create_course(['startdate' => time(), 'enablecompletion' => 1]);
+
+        // Enrol a user in the course.
+        $user = $this->getDataGenerator()->create_user();
+        $studentrole = $DB->get_record('role', ['shortname' => 'student']);
+        $this->getDataGenerator()->enrol_user($user->id, $course->id, $studentrole->id);
+        // Complete the course with a user.
+        $ccompletion = new completion_completion(['course' => $course->id,
+                                                  'userid' => $user->id,
+                                                  'timeenrolled' => time(),
+                                                  'timestarted' => time()
+                                                ]);
+        // Now, mark the course as completed.
+        $ccompletion->mark_complete();
+        $this->assertEquals('100', \core_completion\progress::get_course_progress_percentage($course, $user->id));
+
+        // Back up and restore.
+        $newcourseid = $this->backup_and_restore($course);
+        $newcourse = get_course($newcourseid);
+
+        $newcompletion = completion_completion::fetch(['course' => $newcourseid, 'userid' => $user->id]);
+
+        // Compare dates.
+        $this->assertEquals($ccompletion->timeenrolled, $newcompletion->timeenrolled);
+        $this->assertEquals($ccompletion->timestarted, $newcompletion->timestarted);
+        $this->assertEquals($ccompletion->timecompleted, $newcompletion->timecompleted);
+    }
+
+    /**
+     * Testing that the grade grade date information is not changed in the gradebook when a course
+     * restore is performed.
+     */
+    public function test_grade_grade_date_restore() {
+        global $USER, $DB;
+        // Testing the restore of an overridden grade.
+        list($course, $assign) = $this->create_course_and_module('assign', []);
+        $cm = $DB->get_record('course_modules', ['course' => $course->id, 'instance' => $assign->id]);
+        $assignobj = new testable_assign(context_module::instance($cm->id), $cm, $course);
+        $submission = $assignobj->get_user_submission($USER->id, true);
+        $grade = $assignobj->get_user_grade($USER->id, true);
+        $grade->grade = 75;
+        $assignobj->update_grade($grade);
+
+        // Find the grade item.
+        $gradeitemparams = [
+            'itemtype' => 'mod',
+            'iteminstance' => $assign->id,
+            'itemmodule' => 'assign',
+            'courseid' => $course->id,
+        ];
+        $gradeitem = grade_item::fetch($gradeitemparams);
+
+        // Next the grade grade.
+        $gradegrade = grade_grade::fetch(['itemid' => $gradeitem->id, 'userid' => $USER->id]);
+        $gradegrade->set_overridden(true);
+
+        // Back up and restore.
+        $newcourseid = $this->backup_and_restore($course);
+        $newcourse = get_course($newcourseid);
+
+        // Find assignment.
+        $assignid = $DB->get_field('assign', 'id', ['course' => $newcourseid]);
+        // Find grade item.
+        $newgradeitemparams = [
+            'itemtype' => 'mod',
+            'iteminstance' => $assignid,
+            'itemmodule' => 'assign',
+            'courseid' => $newcourse->id,
+        ];
+
+        $newgradeitem = grade_item::fetch($newgradeitemparams);
+        // Find grade grade.
+        $newgradegrade = grade_grade::fetch(['itemid' => $newgradeitem->id, 'userid' => $USER->id]);
+        // Compare dates.
+        $this->assertEquals($gradegrade->timecreated, $newgradegrade->timecreated);
+        $this->assertEquals($gradegrade->timemodified, $newgradegrade->timemodified);
+        $this->assertEquals($gradegrade->overridden, $newgradegrade->overridden);
+    }
+
+    /**
+     * Checking that the user completion of an activity relating to the timemodified field does not change
+     * when doing a course restore.
+     */
+    public function test_usercompletion_date_restore() {
+        global $USER, $DB;
+        // More completion...
+        $course = $this->getDataGenerator()->create_course(['startdate' => time(), 'enablecompletion' => 1]);
+        $assign = $this->getDataGenerator()->create_module('assign', [
+                'course' => $course->id,
+                'completion' => COMPLETION_TRACKING_AUTOMATIC, // Show activity as complete when conditions are met.
+                'completionusegrade' => 1 // Student must receive a grade to complete this activity.
+            ]);
+        $cm = $DB->get_record('course_modules', ['course' => $course->id, 'instance' => $assign->id]);
+        $assignobj = new testable_assign(context_module::instance($cm->id), $cm, $course);
+        $submission = $assignobj->get_user_submission($USER->id, true);
+        $grade = $assignobj->get_user_grade($USER->id, true);
+        $grade->grade = 75;
+        $assignobj->update_grade($grade);
+
+        $coursemodulecompletion = $DB->get_record('course_modules_completion', ['coursemoduleid' => $cm->id]);
+
+        // Back up and restore.
+        $newcourseid = $this->backup_and_restore($course);
+        $newcourse = get_course($newcourseid);
+
+        // Find assignment.
+        $assignid = $DB->get_field('assign', 'id', ['course' => $newcourseid]);
+        $cm = $DB->get_record('course_modules', ['course' => $newcourse->id, 'instance' => $assignid]);
+        $newcoursemodulecompletion = $DB->get_record('course_modules_completion', ['coursemoduleid' => $cm->id]);
+
+        $this->assertEquals($coursemodulecompletion->timemodified, $newcoursemodulecompletion->timemodified);
+    }
+
+    /**
+     * Ensuring that the timemodified field of the question attempt steps table does not change when
+     * a course restore is done.
+     */
+    public function test_question_attempt_steps_date_restore() {
+        global $DB;
+
+        $course = $this->getDataGenerator()->create_course(['startdate' => time()]);
+        // Make a quiz.
+        $quizgenerator = $this->getDataGenerator()->get_plugin_generator('mod_quiz');
+
+        $quiz = $quizgenerator->create_instance(array('course' => $course->id, 'questionsperpage' => 0, 'grade' => 100.0,
+                                                      'sumgrades' => 2));
+
+        $cm = $DB->get_record('course_modules', ['course' => $course->id, 'instance' => $quiz->id]);
+
+        // Create a couple of questions.
+        $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question');
+
+        $cat = $questiongenerator->create_question_category();
+        $saq = $questiongenerator->create_question('shortanswer', null, array('category' => $cat->id));
+        $numq = $questiongenerator->create_question('numerical', null, array('category' => $cat->id));
+
+        // Add them to the quiz.
+        quiz_add_quiz_question($saq->id, $quiz);
+        quiz_add_quiz_question($numq->id, $quiz);
+
+        // Make a user to do the quiz.
+        $user1 = $this->getDataGenerator()->create_user();
+
+        $quizobj = quiz::create($quiz->id, $user1->id);
+
+        // Start the attempt.
+        $quba = question_engine::make_questions_usage_by_activity('mod_quiz', $quizobj->get_context());
+        $quba->set_preferred_behaviour($quizobj->get_quiz()->preferredbehaviour);
+
+        $timenow = time();
+        $attempt = quiz_create_attempt($quizobj, 1, false, $timenow, false, $user1->id);
+
+        quiz_start_new_attempt($quizobj, $quba, $attempt, 1, $timenow);
+
+        quiz_attempt_save_started($quizobj, $quba, $attempt);
+
+        // Process some responses from the student.
+        $attemptobj = quiz_attempt::create($attempt->id);
+
+        $prefix1 = $quba->get_field_prefix(1);
+        $prefix2 = $quba->get_field_prefix(2);
+
+        $tosubmit = array(1 => array('answer' => 'frog'),
+                          2 => array('answer' => '3.14'));
+
+        $attemptobj->process_submitted_actions($timenow, false, $tosubmit);
+
+        // Finish the attempt.
+        $attemptobj = quiz_attempt::create($attempt->id);
+        $attemptobj->process_finish($timenow, false);
+
+        $questionattemptstepdates = [];
+        $originaliterator = $quba->get_attempt_iterator();
+        foreach ($originaliterator as $questionattempt) {
+            $questionattemptstepdates[] = ['originaldate' => $questionattempt->get_last_action_time()];
+        }
+
+        // Back up and restore.
+        $newcourseid = $this->backup_and_restore($course);
+        $newcourse = get_course($newcourseid);
+
+        // Get the quiz for this new restored course.
+        $quizdata = $DB->get_record('quiz', ['course' => $newcourseid]);
+        $quizobj = quiz::create($quizdata->id, $user1->id);
+
+        $questionusage = $DB->get_record('question_usages', [
+                'component' => 'mod_quiz',
+                'contextid' => $quizobj->get_context()->id
+            ]);
+
+        $newquba = question_engine::load_questions_usage_by_activity($questionusage->id);
+
+        $restorediterator = $newquba->get_attempt_iterator();
+        $i = 0;
+        foreach ($restorediterator as $restoredquestionattempt) {
+            $questionattemptstepdates[$i]['restoredate'] = $restoredquestionattempt->get_last_action_time();
+            $i++;
+        }
+
+        foreach ($questionattemptstepdates as $dates) {
+            $this->assertEquals($dates['originaldate'], $dates['restoredate']);
+        }
+    }
+}
\ No newline at end of file
index f3b5925..9ccd887 100644 (file)
@@ -56,8 +56,10 @@ class backup_anonymizer_helper {
         if (preg_match('/^anon\d*$/', $user->username)) {
             $match = preg_match('/^anonfirstname\d*$/', $user->firstname);
             $match = $match && preg_match('/^anonlastname\d*$/', $user->lastname);
-            $match = $match && preg_match('/^anon\d*@doesntexist\.com$/', $user->email);
-            if ($match) {
+            // Check .com for backwards compatibility.
+            $emailmatch = preg_match('/^anon\d*@doesntexist\.com$/', $user->email) ||
+                preg_match('/^anon\d*@doesntexist\.invalid$/', $user->email);
+            if ($match && $emailmatch) {
                 return true;
             }
         }
@@ -93,7 +95,7 @@ class backup_anonymizer_helper {
     public static function process_user_email($value) {
         static $counter = 0;
         $counter++;
-        return 'anon' . $counter . '@doesntexist.com'; // Just a counter
+        return 'anon' . $counter . '@doesntexist.invalid'; // Just a counter.
     }
 
     public static function process_user_icq($value) {
index 2fcb469..954ecb0 100644 (file)
@@ -119,6 +119,21 @@ class backup_plan extends base_plan implements loggable {
         $this->controller->set_status(backup::STATUS_EXECUTING);
         parent::execute();
         $this->controller->set_status(backup::STATUS_FINISHED_OK);
+
+        if ($this->controller->get_type() === backup::TYPE_1COURSE) {
+            // Trigger a course_backup_created event.
+            $otherarray = array('format' => $this->controller->get_format(),
+                                'mode' => $this->controller->get_mode(),
+                                'interactive' => $this->controller->get_interactive(),
+                                'type' => $this->controller->get_type(),
+            );
+            $event = \core\event\course_backup_created::create(array(
+                'objectid' => $this->get_courseid(),
+                'context' => context_course::instance($this->get_courseid()),
+                'other' => $otherarray
+            ));
+            $event->trigger();
+        }
     }
 }
 
index c72f339..da9a103 100644 (file)
@@ -51,6 +51,8 @@ abstract class restore_step extends base_step {
      * Note we are using one static cache here, but *by restoreid*, so it's ok for concurrence/multiple
      * executions in the same request
      *
+     * Note: The policy is to roll date only for configurations and not for user data. see MDL-9367.
+     *
      * @param int $value Time value (seconds since epoch), or empty for nothing
      * @return int Time value after applying the date offset, or empty for nothing
      */
index a2886be..d34b8d5 100644 (file)
@@ -169,8 +169,7 @@ Feature: Restore Moodle 2 course backups
     When I backup "Course 1" course using this options:
       | Initial |  Include enrolled users | 0 |
       | Confirmation | Filename | test_backup.mbz |
-    And I am on site homepage
-    And I follow "Course 2"
+    And I am on "Course 2" course homepage
     And I navigate to "Restore" node in "Course administration"
     And I merge "test_backup.mbz" backup into the current course after deleting it's contents using this options:
       | Schema | Overwrite course configuration | Yes |
@@ -199,8 +198,7 @@ Feature: Restore Moodle 2 course backups
     When I backup "Course 1" course using this options:
       | Initial |  Include enrolled users | 0 |
       | Confirmation | Filename | test_backup.mbz |
-    And I am on site homepage
-    And I follow "Course 2"
+    And I am on "Course 2" course homepage
     And I navigate to "Restore" node in "Course administration"
     And I merge "test_backup.mbz" backup into the current course after deleting it's contents using this options:
       | Schema | Overwrite course configuration | No |
@@ -229,8 +227,7 @@ Feature: Restore Moodle 2 course backups
     When I backup "Course 1" course using this options:
       | Initial |  Include enrolled users | 0 |
       | Confirmation | Filename | test_backup.mbz |
-    And I am on site homepage
-    And I follow "Course 4"
+    And I am on "Course 4" course homepage
     And I navigate to "Restore" node in "Course administration"
     And I merge "test_backup.mbz" backup into the current course after deleting it's contents using this options:
       | Schema | Overwrite course configuration | No |
index 171361b..8e9ed2e 100644 (file)
@@ -47,8 +47,13 @@ class award_criteria_activity extends award_criteria {
         parent::__construct($record);
 
         $this->course = $DB->get_record_sql('SELECT c.id, c.enablecompletion, c.cacherev, c.startdate
-                        FROM {badge} b INNER JOIN {course} c ON b.courseid = c.id
+                        FROM {badge} b LEFT JOIN {course} c ON b.courseid = c.id
                         WHERE b.id = :badgeid ', array('badgeid' => $this->badgeid), MUST_EXIST);
+
+        // If the course doesn't exist but we're sure the badge does (thanks to the LEFT JOIN), then use the site as the course.
+        if (empty($this->course->id)) {
+            $this->course = get_course(SITEID);
+        }
         $this->courseid = $this->course->id;
     }
 
index c8ae554..9f5b41a 100644 (file)
@@ -49,8 +49,13 @@ class award_criteria_course extends award_criteria {
         parent::__construct($record);
 
         $this->course = $DB->get_record_sql('SELECT c.id, c.enablecompletion, c.cacherev, c.startdate
-                        FROM {badge} b INNER JOIN {course} c ON b.courseid = c.id
+                        FROM {badge} b LEFT JOIN {course} c ON b.courseid = c.id
                         WHERE b.id = :badgeid ', array('badgeid' => $this->badgeid), MUST_EXIST);
+
+        // If the course doesn't exist but we're sure the badge does (thanks to the LEFT JOIN), then use the site as the course.
+        if (empty($this->course->id)) {
+            $this->course = get_course(SITEID);
+        }
         $this->courseid = $this->course->id;
     }
 
index 2f24252..63e6dba 100644 (file)
@@ -109,7 +109,6 @@ Feature: Block activity modules
       | workshop   | Test workshop name     | Test workshop description     | C1     | workshop1   |
 
     When I log in as "admin"
-    And I am on course index
     And I am on "Course 1" course homepage with editing mode on
     And I add the "Activities" block
     And I click on "Assignments" "link" in the "Activities" "block"
index 5103f23..982510d 100644 (file)
@@ -22,7 +22,7 @@ Feature: The activity results block doesn't display student scores for unsupport
       | Assignment name | Test assignment |
       | Description | Offline text |
       | assignsubmission_file_enabled | 0 |
-    And I follow "C1"
+    And I am on "Course 1" course homepage
     And I add the "Activity results" block
     And I configure the "Activity results" block
     And I set the following fields to these values:
index 67ee460..8814cc4 100644 (file)
@@ -32,7 +32,7 @@ Feature: Students can use the recent blog entries block to view recent entries o
     And I press "Save changes"
     Then I should see "S1 First Blog"
     And I should see "This is my awesome blog!"
-    And I follow "C1"
+    And I am on "Course 1" course homepage
     And I should see "S1 First Blog"
     And I follow "S1 First Blog"
     And I should see "This is my awesome blog!"
@@ -47,7 +47,7 @@ Feature: Students can use the recent blog entries block to view recent entries o
       | Blog entry body | This is my awesome blog! |
     And I press "Save changes"
     And I wait "1" seconds
-    And I follow "C1"
+    And I am on "Course 1" course homepage
     And I follow "Add an entry about this course"
     # Blog 2 of 5
     And I set the following fields to these values:
@@ -57,7 +57,7 @@ Feature: Students can use the recent blog entries block to view recent entries o
     And I wait "1" seconds
     And I should see "S1 Second Blog"
     And I should see "This is my awesome blog!"
-    And I follow "C1"
+    And I am on "Course 1" course homepage
     And I follow "Add an entry about this course"
     # Blog 3 of 5
     And I set the following fields to these values:
@@ -67,7 +67,7 @@ Feature: Students can use the recent blog entries block to view recent entries o
     And I wait "1" seconds
     And I should see "S1 Third Blog"
     And I should see "This is my awesome blog!"
-    And I follow "C1"
+    And I am on "Course 1" course homepage
     And I follow "Add an entry about this course"
     # Blog 4 of 5
     And I set the following fields to these values:
@@ -77,7 +77,7 @@ Feature: Students can use the recent blog entries block to view recent entries o
     And I wait "1" seconds
     And I should see "S1 Fourth Blog"
     And I should see "This is my awesome blog!"
-    And I follow "C1"
+    And I am on "Course 1" course homepage
     And I follow "Add an entry about this course"
     # Blog 5 of 5
     And I set the following fields to these values:
@@ -86,7 +86,7 @@ Feature: Students can use the recent blog entries block to view recent entries o
     And I press "Save changes"
     And I should see "S1 Fifth Blog"
     And I should see "This is my awesome blog!"
-    When I follow "C1"
+    When I am on "Course 1" course homepage
     And I should not see "S1 First Blog"
     And I should see "S1 Second Blog"
     And I should see "S1 Third Blog"
index db6f058..8753167 100644 (file)
@@ -81,7 +81,6 @@ Feature: Enable the calendar block in a course and test it's functionality
     And I create a calendar event with form data:
       | id_eventtype | User |
       | id_name | User Event |
-    When I am on homepage
     And I am on "Course 1" course homepage
     And I follow "Hide course events"
     And I hover over today in the calendar
@@ -96,8 +95,7 @@ Feature: Enable the calendar block in a course and test it's functionality
     And I create a calendar event with form data:
       | id_eventtype | User |
       | id_name | User Event |
-    When I am on homepage
-    And I am on "Course 1" course homepage
+    When I am on "Course 1" course homepage
     And I hover over today in the calendar
     Then I should see "User Event"
 
@@ -113,8 +111,7 @@ Feature: Enable the calendar block in a course and test it's functionality
     And I create a calendar event with form data:
       | id_eventtype | User |
       | id_name | User Event |
-    When I am on homepage
-    And I am on "Course 1" course homepage
+    When I am on "Course 1" course homepage
     And I follow "Hide user events"
     And I hover over today in the calendar
     Then I should not see "User Event"
@@ -141,7 +138,6 @@ Feature: Enable the calendar block in a course and test it's functionality
     And I add the "Calendar" block
     And I create a calendar event with form data:
       | id_eventtype | Group |
-      | id_groupid | Group 1 |
       | id_name | Group Event |
     And I log out
     Then I log in as "student1"
@@ -179,7 +175,6 @@ Feature: Enable the calendar block in a course and test it's functionality
     And I am on "Course 1" course homepage
     And I create a calendar event with form data:
       | id_eventtype | Group |
-      | id_groupid | Group 1 |
       | id_name | Group Event 1 |
     And I log out
     Then I log in as "student1"
index d517c5f..9e092bf 100644 (file)
@@ -4,7 +4,7 @@ Feature: Enable the upcoming events block in a course
   As a teacher
   I can view the event in the upcoming events block
 
-  Scenario: View a global event in the upcoming events block in a course
+  Background:
     Given the following "users" exist:
       | username | firstname | lastname | email | idnumber |
       | teacher1 | Teacher | 1 | teacher1@example.com | T1 |
@@ -14,7 +14,10 @@ Feature: Enable the upcoming events block in a course
     And the following "course enrolments" exist:
       | user | course | role |
       | teacher1 | C1 | editingteacher |
-    When I log in as "admin"
+
+  @javascript
+  Scenario: View a global event in the calendar block
+    Given I log in as "admin"
     And I create a calendar event with form data:
       | id_eventtype | Site |
       | id_name | My Site Event |
index ceb1898..0b7c739 100644 (file)
@@ -3,12 +3,14 @@ Feature: View a upcoming site event on the dashboard
   In order to view a site event
   As a student
   I can view the event in the upcoming events block
-
-  Scenario: View a global event in the upcoming events block on the dashboard
+  Background:
     Given the following "users" exist:
       | username | firstname | lastname | email | idnumber |
       | student1 | Student | 1 | student1@example.com | S1 |
-    And I log in as "admin"
+
+  @javascript
+  Scenario: View a global event in the upcoming events block on the dashboard
+    Given I log in as "admin"
     And I create a calendar event with form data:
       | id_eventtype | Site |
       | id_name | My Site Event |
index a101952..448b710 100644 (file)
@@ -4,11 +4,14 @@ Feature: View a site event on the frontpage
   As a teacher
   I can view the event in the upcoming events block
 
-  Scenario: View a global event in the upcoming events block on the frontpage
+  Background:
     Given the following "users" exist:
       | username | firstname | lastname | email | idnumber |
       | teacher1 | Teacher | 1 | teacher1@example.com | T1 |
-    And I log in as "admin"
+
+  @javascript
+  Scenario: View a global event in the upcoming events block on the frontpage
+    Given I log in as "admin"
     And I create a calendar event with form data:
       | id_eventtype | Site |
       | id_name | My Site Event |
index c515911..a61b273 100644 (file)
@@ -109,6 +109,13 @@ class community_hub_search_form extends moodleform {
             $mform->addElement('static', 'errorhub', '', $error);
         }
 
+        // Hubdirectory returns old URL for the moodle.net hub, substitute it.
+        foreach ($hubs as $key => $hub) {
+            if ($hub['url'] === HUB_OLDMOODLEORGHUBURL) {
+                $hubs[$key]['url'] = HUB_MOODLEORGHUBURL;
+            }
+        }
+
         //display list of registered on hub
         $registrationmanager = new registration_manager();
         $registeredhubs = $registrationmanager->get_registered_on_hubs();
index a92c042..d41fe98 100644 (file)
@@ -60,7 +60,7 @@ Feature: Enable Block Completion in a course using activity completion
     When I log in as "student1"
     And I am on "Course 1" course homepage
     And I follow "Test page name"
-    And I follow "C1"
+    And I am on "Course 1" course homepage
     Then I should see "Status: Pending" in the "Course completion status" "block"
     And I should see "0 of 1" in the "Activity completion" "table_row"
     And I trigger cron
index 2949c09..2f8cb9a 100644 (file)
@@ -83,6 +83,9 @@ class courses_view implements renderable, templatable {
             // Convert summary to plain text.
             $exportedcourse->summary = content_to_text($exportedcourse->summary, $exportedcourse->summaryformat);
 
+            // Include course visibility.
+            $exportedcourse->visible = (bool)$course->visible;
+
             $courseprogress = null;
 
             $classified = course_classify_for_timeline($course);
index 74922d6..d9e729a 100644 (file)
@@ -22,6 +22,9 @@
 
     Example context (json):
     {
+        "urls": {
+            "noevents": "#"
+        }
     }
 }}
 <div data-region="event-list-container"
@@ -93,7 +96,7 @@
              alt="{{#str}} noevents, block_myoverview {{/str}}"
              role="presentation">
         <p class="text-muted m-t-1">{{#str}} noevents, block_myoverview {{/str}}</p>
-        <a href="{{viewurl}}" class="btn btn-secondary text-primary"
+        <a href="{{viewurl}}" class="btn btn-secondary {{#visible}}text-primary{{/visible}}"
            aria-label="{{#str}} viewcoursename, block_myoverview, {{fullnamedisplay}} {{/str}}">
             {{#str}} viewcourse, block_myoverview {{/str}}
         </a>
index 21368a7..acc214b 100644 (file)
 <div class="course-info-container" id="course-info-container-{{id}}">
     <div class="hidden-sm-up hidden-tablet hidden-phone">
         {{> block_myoverview/progress-chart}}
-        <h4 class="h5"><a href="{{viewurl}}">{{{fullnamedisplay}}}</a></h4>
+        <h4 class="h5"><a href="{{viewurl}}" class="{{^visible}}dimmed{{/visible}}">{{{fullnamedisplay}}}</a></h4>
     </div>
     <div class="hidden-sm-down hidden-tablet hidden-desktop">
         {{> block_myoverview/progress-chart}}
-        <h4 class="h5"><a href="{{viewurl}}">{{{fullnamedisplay}}}</a></h4>
+        <h4 class="h5"><a href="{{viewurl}}" class="{{^visible}}dimmed{{/visible}}">{{{fullnamedisplay}}}</a></h4>
     </div>
     <div class="hidden-xs-down hidden-md-up visible-tablet">
         <div class="media">
@@ -43,7 +43,7 @@
                 </div>
             </div>
             <div class="media-body">
-                <h4 class="h5"><a href="{{viewurl}}">{{{fullnamedisplay}}}</a></h4>
+                <h4 class="h5"><a href="{{viewurl}}" class="{{^visible}}dimmed{{/visible}}">{{{fullnamedisplay}}}</a></h4>
             </div>
         </div>
     </div>
index b2f39a8..fd8a4fb 100644 (file)
@@ -31,7 +31,7 @@
         <div class="card-block course-info-container" id="course-info-container-{{id}}">
             <div class="hidden-sm-up hidden-phone">
                 {{> block_myoverview/progress-chart}}
-                <h4 class="h5"><a href="{{viewurl}}">{{{fullnamedisplay}}}</a></h4>
+                <h4 class="h5"><a href="{{viewurl}}" class="{{^visible}}dimmed{{/visible}}">{{{fullnamedisplay}}}</a></h4>
             </div>
             <div class="hidden-xs-down visible-phone">
                 <div class="media">
@@ -41,7 +41,7 @@
                         </div>
                     </div>
                     <div class="media-body">
-                        <h4 class="h5"><a href="{{viewurl}}">{{{fullnamedisplay}}}</a></h4>
+                        <h4 class="h5"><a href="{{viewurl}}" class="{{^visible}}dimmed{{/visible}}">{{{fullnamedisplay}}}</a></h4>
                     </div>
                 </div>
             </div>
index a850602..5fa4a0b 100644 (file)
@@ -29,7 +29,7 @@ Feature: People Block used in a course
     When I log in as "student1"
     And I am on "Course 1" course homepage
     And I click on "Participants" "link" in the "People" "block"
-    Then I should see "All participants" in the "#page-content" "css_element"
+    Then I should see "Participants" in the "#page-content" "css_element"
 
   Scenario: Student without permission can not view participants link
     Given the following "permission overrides" exist:
index d54e1ca..59256a9 100644 (file)
Binary files a/calendar/amd/build/calendar.min.js and b/calendar/amd/build/calendar.min.js differ
diff --git a/calendar/amd/build/calendar_events.min.js b/calendar/amd/build/calendar_events.min.js
deleted file mode 100644 (file)
index 5496507..0000000
Binary files a/calendar/amd/build/calendar_events.min.js and /dev/null differ
diff --git a/calendar/amd/build/calendar_repository.min.js b/calendar/amd/build/calendar_repository.min.js
deleted file mode 100644 (file)
index bfb5d0d..0000000
Binary files a/calendar/amd/build/calendar_repository.min.js and /dev/null differ
diff --git a/calendar/amd/build/event_form.min.js b/calendar/amd/build/event_form.min.js
new file mode 100644 (file)
index 0000000..49c3a88
Binary files /dev/null and b/calendar/amd/build/event_form.min.js differ
diff --git a/calendar/amd/build/events.min.js b/calendar/amd/build/events.min.js
new file mode 100644 (file)
index 0000000..ca56cb1
Binary files /dev/null and b/calendar/amd/build/events.min.js differ
diff --git a/calendar/amd/build/modal_event_form.min.js b/calendar/amd/build/modal_event_form.min.js
new file mode 100644 (file)
index 0000000..78b804a
Binary files /dev/null and b/calendar/amd/build/modal_event_form.min.js differ
diff --git a/calendar/amd/build/repository.min.js b/calendar/amd/build/repository.min.js
new file mode 100644 (file)
index 0000000..8cc9d4b
Binary files /dev/null and b/calendar/amd/build/repository.min.js differ
index 3c917ad..358bad0 100644 (file)
Binary files a/calendar/amd/build/summary_modal.min.js and b/calendar/amd/build/summary_modal.min.js differ
diff --git a/calendar/amd/build/view_manager.min.js b/calendar/amd/build/view_manager.min.js
new file mode 100644 (file)
index 0000000..5cc674c
Binary files /dev/null and b/calendar/amd/build/view_manager.min.js differ
index 5de6318..b1f37e7 100644 (file)
 // along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
 
 /**
- * A javascript module to calendar events.
+ * This module is the highest level module for the calendar. It is
+ * responsible for initialising all of the components required for
+ * the calendar to run. It also coordinates the interaction between
+ * components by listening for and responding to different events
+ * triggered within the calendar UI.
  *
  * @module     core_calendar/calendar
  * @package    core_calendar
  * @copyright  2017 Simey Lameze <simey@moodle.com>
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
-define(['jquery', 'core/ajax', 'core/str', 'core/templates', 'core/notification', 'core/custom_interaction_events',
-        'core/modal_factory', 'core_calendar/summary_modal', 'core/modal_events', 'core_calendar/calendar_repository'],
-    function($, Ajax, Str, Templates, Notification, CustomEvents, ModalFactory, SummaryModal, ModalEvents, CalendarRepository) {
-
-        var SELECTORS = {
-            ROOT: "[data-region='calendar']",
-            EVENT_LINK: "[data-action='view-event']",
-        };
-
-        /**
-         * Get the event type lang string.
-         *
-         * @param {String} eventType The event type.
-         * @return {promise} The lang string promise.
-         */
-        var getEventType = function(eventType) {
-            var lang = 'type' + eventType;
-            return Str.get_string(lang, 'core_calendar').then(function(langStr) {
-                return langStr;
-            });
-        };
-
-        /**
-         * Get the event source.
-         *
-         * @param {Object} subscription The event subscription object.
-         * @return {promise} The lang string promise.
-         */
-        var getEventSource = function(subscription) {
-            return Str.get_string('subsource', 'core_calendar', subscription).then(function(langStr) {
-                if (subscription.url) {
-                    return '<a href="' + subscription.url + '">' + langStr + '</a>';
-                }
-                return langStr;
-            });
-        };
-
-        /**
-         * Render the event summary modal.
-         *
-         * @param {Number} eventId The calendar event id.
-         */
-        var renderEventSummaryModal = function(eventId) {
-            // Calendar repository promise.
-            CalendarRepository.getEventById(eventId).then(function(getEventResponse) {
-                if (!getEventResponse.event) {
-                    throw new Error('Error encountered while trying to fetch calendar event with ID: ' + eventId);
-                }
-                var eventData = getEventResponse.event;
-                var eventTypePromise = getEventType(eventData.eventtype);
-
-                // If the calendar event has event source, get the source's language string/link.
-                if (eventData.displayeventsource) {
-                    eventData.subscription = JSON.parse(eventData.subscription);
-                    var eventSourceParams = {
-                        url: eventData.subscription.url,
-                        name: eventData.subscription.name
-                    };
-                    var eventSourcePromise = getEventSource(eventSourceParams);
-
-                    // Return event data with event type and event source info.
-                    return $.when(eventTypePromise, eventSourcePromise).then(function(eventType, eventSource) {
-                        eventData.eventtype = eventType;
-                        eventData.source = eventSource;
-                        return eventData;
-                    });
-                }
+define([
+            'jquery',
+            'core/ajax',
+            'core/str',
+            'core/templates',
+            'core/notification',
+            'core/custom_interaction_events',
+            'core/modal_events',
+            'core/modal_factory',
+            'core_calendar/modal_event_form',
+            'core_calendar/summary_modal',
+            'core_calendar/repository',
+            'core_calendar/events',
+            'core_calendar/view_manager'
+        ],
+        function(
+            $,
+            Ajax,
+            Str,
+            Templates,
+            Notification,
+            CustomEvents,
+            ModalEvents,
+            ModalFactory,
+            ModalEventForm,
+            SummaryModal,
+            CalendarRepository,
+            CalendarEvents,
+            CalendarViewManager
+        ) {
+
+    var SELECTORS = {
+        ROOT: "[data-region='calendar']",
+        EVENT_LINK: "[data-action='view-event']",
+        NEW_EVENT_BUTTON: "[data-action='new-event-button']"
+    };
+
+    /**
+     * Get the event type lang string.
+     *
+     * @param {String} eventType The event type.
+     * @return {promise} The lang string promise.
+     */
+    var getEventType = function(eventType) {
+        var lang = 'type' + eventType;
+        return Str.get_string(lang, 'core_calendar').then(function(langStr) {
+            return langStr;
+        });
+    };
+
+    /**
+     * Get the event source.
+     *
+     * @param {Object} subscription The event subscription object.
+     * @return {promise} The lang string promise.
+     */
+    var getEventSource = function(subscription) {
+        return Str.get_string('subsource', 'core_calendar', subscription).then(function(langStr) {
+            if (subscription.url) {
+                return '<a href="' + subscription.url + '">' + langStr + '</a>';
+            }
+            return langStr;
+        });
+    };
 
-                // Return event data with event type info.
-                return eventTypePromise.then(function(eventType) {
+    /**
+     * Render the event summary modal.
+     *
+     * @param {Number} eventId The calendar event id.
+     */
+    var renderEventSummaryModal = function(eventId) {
+        // Calendar repository promise.
+        CalendarRepository.getEventById(eventId).then(function(getEventResponse) {
+            if (!getEventResponse.event) {
+                throw new Error('Error encountered while trying to fetch calendar event with ID: ' + eventId);
+            }
+            var eventData = getEventResponse.event;
+            var eventTypePromise = getEventType(eventData.eventtype);
+
+            // If the calendar event has event source, get the source's language string/link.
+            if (eventData.displayeventsource) {
+                eventData.subscription = JSON.parse(eventData.subscription);
+                var eventSourceParams = {
+                    url: eventData.subscription.url,
+                    name: eventData.subscription.name
+                };
+                var eventSourcePromise = getEventSource(eventSourceParams);
+
+                // Return event data with event type and event source info.
+                return $.when(eventTypePromise, eventSourcePromise).then(function(eventType, eventSource) {
                     eventData.eventtype = eventType;
+                    eventData.source = eventSource;
                     return eventData;
                 });
+            }
 
-            }).then(function(eventData) {
-                // Build the modal parameters from the event data.
-                var modalParams = {
-                    title: eventData.name,
-                    type: SummaryModal.TYPE,
-                    body: Templates.render('core_calendar/event_summary_body', eventData)
-                };
-                if (!eventData.caneditevent) {
-                    modalParams.footer = '';
+            // Return event data with event type info.
+            return eventTypePromise.then(function(eventType) {
+                eventData.eventtype = eventType;
+                return eventData;
+            });
+
+        }).then(function(eventData) {
+            // Build the modal parameters from the event data.
+            var modalParams = {
+                title: eventData.name,
+                type: SummaryModal.TYPE,
+                body: Templates.render('core_calendar/event_summary_body', eventData),
+                templateContext: {
+                    canedit: eventData.canedit,
+                    candelete: eventData.candelete
                 }
-                // Create the modal.
-                return ModalFactory.create(modalParams);
-
-            }).done(function(modal) {
-                // Handle hidden event.
-                modal.getRoot().on(ModalEvents.hidden, function() {
-                    // Destroy when hidden.
-                    modal.destroy();
-                });
+            };
 
-                // Finally, render the modal!
-                modal.show();
+            // Create the modal.
+            return ModalFactory.create(modalParams);
 
-            }).fail(Notification.exception);
-        };
-
-        /**
-         * Register event listeners for the module.
-         */
-        var registerEventListeners = function() {
-            // Bind click events to event links.
-            $(SELECTORS.EVENT_LINK).click(function(e) {
-                e.preventDefault();
-                var eventId = $(this).attr('data-event-id');
-                renderEventSummaryModal(eventId);
+        }).done(function(modal) {
+            // Handle hidden event.
+            modal.getRoot().on(ModalEvents.hidden, function() {
+                // Destroy when hidden.
+                modal.destroy();
             });
-        };
 
-        return {
-            init: function() {
-                registerEventListeners();
-            }
-        };
-    });
+            // Finally, render the modal!
+            modal.show();
+
+        }).fail(Notification.exception);
+    };
+
+    /**
+     * Create the event form modal for creating new events and
+     * editing existing events.
+     *
+     * @method registerEventFormModal
+     * @param {object} root The calendar root element
+     * @return {object} The create modal promise
+     */
+    var registerEventFormModal = function(root) {
+        var newEventButton = root.find(SELECTORS.NEW_EVENT_BUTTON);
+        var contextId = newEventButton.attr('data-context-id');
+
+        return ModalFactory.create(
+            {
+                type: ModalEventForm.TYPE,
+                large: true,
+                templateContext: {
+                    contextid: contextId
+                }
+            },
+            [root, SELECTORS.NEW_EVENT_BUTTON]
+        );
+    };
+
+    /**
+     * Listen to and handle any calendar events fired by the calendar UI.
+     *
+     * @method registerCalendarEventListeners
+     * @param {object} root The calendar root element
+     * @param {object} eventFormModalPromise A promise reolved with the event form modal
+     */
+    var registerCalendarEventListeners = function(root, eventFormModalPromise) {
+        var body = $('body');
+
+        // TODO: Replace these with actual logic to update
+        // the UI without having to force a page reload.
+        body.on(CalendarEvents.created, function() {
+            window.location.reload();
+        });
+        body.on(CalendarEvents.deleted, function() {
+            CalendarViewManager.reloadCurrentMonth();
+        });
+        body.on(CalendarEvents.updated, function() {
+            window.location.reload();
+        });
+        body.on(CalendarEvents.editActionEvent, function(e, url) {
+            // Action events needs to be edit directly on the course module.
+            window.location.assign(url);
+        });
+
+        eventFormModalPromise.then(function(modal) {
+            // When something within the calendar tells us the user wants
+            // to edit an event then show the event form modal.
+            body.on(CalendarEvents.editEvent, function(e, eventId) {
+                modal.setEventId(eventId);
+                modal.show();
+            });
+
+            return;
+        });
+    };
+
+    /**
+     * Register event listeners for the module.
+     */
+    var registerEventListeners = function() {
+        var root = $(SELECTORS.ROOT);
+
+        // Bind click events to event links.
+        root.on('click', SELECTORS.EVENT_LINK, function(e) {
+            e.preventDefault();
+            var eventId = $(this).attr('data-event-id');
+            renderEventSummaryModal(eventId);
+        });
+
+        var eventFormPromise = registerEventFormModal(root);
+        registerCalendarEventListeners(root, eventFormPromise);
+    };
+
+    return {
+        init: function() {
+            CalendarViewManager.init();
+            registerEventListeners();
+        }
+    };
+});
diff --git a/calendar/amd/src/event_form.js b/calendar/amd/src/event_form.js
new file mode 100644 (file)
index 0000000..0a4e33e
--- /dev/null
@@ -0,0 +1,282 @@
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * A javascript module to enhance the event form.
+ *
+ * @module     core_calendar/event_form
+ * @package    core_calendar
+ * @copyright  2017 Ryan Wyllie <ryan@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+define(['jquery', 'core/templates'], function($, Templates) {
+
+    var SELECTORS = {
+        EVENT_TYPE: '[name="eventtype"]',
+        EVENT_COURSE_ID: '[name="courseid"]',
+        EVENT_GROUP_COURSE_ID: '[name="groupcourseid"]',
+        EVENT_GROUP_ID: '[name="groupid"]',
+        FORM_GROUP: '.form-group',
+        SELECT_OPTION: 'option',
+        ADVANCED_ELEMENT: '.fitem.advanced',
+        FIELDSET_ADVANCED_ELEMENTS: 'fieldset.containsadvancedelements',
+        MORELESS_TOGGLE: '.moreless-actions'
+    };
+
+    var EVENT_TYPES = {
+        USER: 'user',
+        SITE: 'site',
+        COURSE: 'course',
+        GROUP: 'group'
+    };
+
+    var EVENTS = {
+        SHOW_ADVANCED: 'event_form-show-advanced',
+        HIDE_ADVANCED: 'event_form-hide-advanced',
+        ADVANCED_SHOWN: 'event_form-advanced-shown',
+        ADVANCED_HIDDEN: 'event_form-advanced-hidden',
+    };
+
+    /**
+     * Find the old show more / show less toggle added by the mform and destroy it.
+     * We are handling the visibility of the advanced fields with the more/less button
+     * in the footer of the modal that this form is rendered within.
+     *
+     * @method destroyOldMoreLessToggle
+     * @param {object} formElement The root form element
+     */
+    var destroyOldMoreLessToggle = function(formElement) {
+        formElement.find(SELECTORS.FIELDSET_ADVANCED_ELEMENTS).removeClass('containsadvancedelements');
+        var element = formElement.find(SELECTORS.MORELESS_TOGGLE);
+        Templates.replaceNode(element, '', '');
+    };
+
+    /**
+     * Find each of the advanced form elements and make them visible.
+     *
+     * This function triggers the ADVANCED_SHOWN event for any other
+     * component to handle (e.g. the event form modal).
+     *
+     * @method destroyOldMoreLessToggle
+     * @param {object} formElement The root form element
+     */
+    var showAdvancedElements = function(formElement) {
+        formElement.find(SELECTORS.ADVANCED_ELEMENT).removeClass('hidden');
+        formElement.trigger(EVENTS.ADVANCED_SHOWN);
+    };
+
+    /**
+     * Find each of the advanced form elements and hide them.
+     *
+     * This function triggers the ADVANCED_HIDDEN event for any other
+     * component to handle (e.g. the event form modal).
+     *
+     * @method hideAdvancedElements
+     * @param {object} formElement The root form element
+     */
+    var hideAdvancedElements = function(formElement) {
+        formElement.find(SELECTORS.ADVANCED_ELEMENT).addClass('hidden');
+        formElement.trigger(EVENTS.ADVANCED_HIDDEN);
+    };
+
+    /**
+     * Listen for any events telling this module to show or hide it's
+     * advanced elements.
+     *
+     * This function listens for SHOW_ADVANCED and HIDE_ADVANCED.
+     *
+     * @method listenForShowHideEvents
+     * @param {object} formElement The root form element
+     */
+    var listenForShowHideEvents = function(formElement) {
+        formElement.on(EVENTS.SHOW_ADVANCED, function() {
+            showAdvancedElements(formElement);
+        });
+
+        formElement.on(EVENTS.HIDE_ADVANCED, function() {
+            hideAdvancedElements(formElement);
+        });
+    };
+
+    /**
+     * Parse the group id select element in the event form and pull out
+     * the course id from the value to allow us to toggle other select
+     * elements based on the course id for the group a user selects.
+     *
+     * This is a little hacky but I couldn't find a better way to pass
+     * the course id for each group id with the limitations of mforms.
+     *
+     * The group id options are rendered with a value like:
+     * "<courseid>-<groupid>"
+     * E.g.
+     * For a group with id 10 in a course with id 3 the value of the
+     * option will be 3-10.
+     *
+     * @method parseGroupSelect
+     * @param {object} formElement The root form element
+     */
+    var parseGroupSelect = function(formElement) {
+        formElement.find(SELECTORS.EVENT_GROUP_ID)
+            .find(SELECTORS.SELECT_OPTION)
+            .each(function(index, element) {
+                element = $(element);
+                var value = element.attr('value');
+                var splits = value.split('-');
+                var courseId = splits[0];
+
+                element.attr('data-course-id', courseId);
+            });
+    };
+
+    /**
+     * Toggle the visibility of the secondary select elements based on
+     * the event type the user has selected.
+     *
+     * There are 3 secondary select elements within the form:
+     *      - course: a list of all courses a user can add course events to
+     *      - group course: a list of all courses a user can add group events to.
+     *                      this list can be different from the course list above.
+     *      - group: a list of all groups a user can add an event to. This list will
+     *               be filtered further based on the group course selected.
+     *
+     *  There are 4 event types:
+     *      - user: none of the secondary selects should be visible.
+     *      - site: none of the secondary selects should be visible.
+     *      - course: "course" select should be visible and both "group course"
+     *                and "group" should be hidden.
+     *      - group: "group course" and "group" should be visible and "course"
+     *               should be hidden.
+     *
+     * @method hideTypeSubSelects
+     * @param {object} formElement The root form element
+     */
+    var hideTypeSubSelects = function(formElement) {
+        var typeSelect = formElement.find(SELECTORS.EVENT_TYPE);
+        var eventType = typeSelect.val();
+        var courseIdSelect = formElement.find(SELECTORS.EVENT_COURSE_ID)
+            .closest(SELECTORS.FORM_GROUP)
+            .removeClass('hidden');
+        var groupCourseIdSelect = formElement.find(SELECTORS.EVENT_GROUP_COURSE_ID)
+            .closest(SELECTORS.FORM_GROUP)
+            .removeClass('hidden');
+        var groupIdSelect = formElement.find(SELECTORS.EVENT_GROUP_ID)
+            .closest(SELECTORS.FORM_GROUP)
+            .removeClass('hidden');
+
+        // Hide the unreleated selectors for the given event type.
+        switch (eventType) {
+            case EVENT_TYPES.COURSE:
+                groupCourseIdSelect.addClass('hidden');
+                groupIdSelect.addClass('hidden');
+                break;
+            case EVENT_TYPES.GROUP:
+                courseIdSelect.addClass('hidden');
+                break;
+            default:
+                courseIdSelect.addClass('hidden');
+                groupCourseIdSelect.addClass('hidden');
+                groupIdSelect.addClass('hidden');
+        }
+    };
+
+    /**
+     * Listen for when the user changes the event type select in the
+     * form and then toggle the visibility of the appropriate secondary
+     * select elements.
+     *
+     * See: hideTypeSubSelects.
+     *
+     * @method addTypeSelectListeners
+     * @param {object} formElement The root form element
+     */
+    var addTypeSelectListeners = function(formElement) {
+        var typeSelect = formElement.find(SELECTORS.EVENT_TYPE);
+
+        typeSelect.on('change', function() {
+            hideTypeSubSelects(formElement);
+        });
+    };
+
+    /**
+     * Listen for when the user changes the group course when configuring
+     * a group event and filter the options in the group select to only
+     * show the groups available within the course the user has selected.
+     *
+     * @method addCourseGroupSelectListeners
+     * @param {object} formElement The root form element
+     */
+    var addCourseGroupSelectListeners = function(formElement) {
+        var courseGroupSelect = formElement.find(SELECTORS.EVENT_GROUP_COURSE_ID);
+        var groupSelect = formElement.find(SELECTORS.EVENT_GROUP_ID);
+        var groupSelectOptions = groupSelect.find(SELECTORS.SELECT_OPTION);
+        var filterGroupSelectOptions = function() {
+            var selectedCourseId = courseGroupSelect.val();
+            var selectedIndex = null;
+
+            groupSelectOptions.each(function(index, element) {
+                element = $(element);
+
+                if (element.attr('data-course-id') == selectedCourseId) {
+                    element.removeClass('hidden');
+                    element.prop('disabled', false);
+
+                    if (selectedIndex === null) {
+                        selectedIndex = index;
+                    }
+                } else {
+                    element.addClass('hidden');
+                    element.prop('disabled', true);
+                }
+            });
+
+            groupSelect.prop('selectedIndex', selectedIndex);
+        };
+
+        courseGroupSelect.on('change', filterGroupSelectOptions);
+        filterGroupSelectOptions();
+    };
+
+    /**
+     * Initialise all of the form enhancements.
+     *
+     * @method init
+     * @param {string} formId The value of the form's id attribute
+     * @param {bool} hasError If the form has errors rendered form the server.
+     */
+    var init = function(formId, hasError) {
+        var formElement = $('#' + formId);
+
+        listenForShowHideEvents(formElement);
+        destroyOldMoreLessToggle(formElement);
+        hideTypeSubSelects(formElement);
+        parseGroupSelect(formElement);
+        addTypeSelectListeners(formElement);
+        addCourseGroupSelectListeners(formElement);
+
+        // If we know that the form has been rendered with server side
+        // errors then we need to display all of the elements in the form
+        // in case one of those elements has the error.
+        if (hasError) {
+            showAdvancedElements(formElement);
+        } else {
+            hideAdvancedElements(formElement);
+        }
+    };
+
+    return {
+        init: init,
+        events: EVENTS,
+    };
+});
similarity index 70%
rename from calendar/amd/src/calendar_events.js
rename to calendar/amd/src/events.js
index bb88d81..465e337 100644 (file)
@@ -14,9 +14,9 @@
 // along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
 
 /**
- * Contain the events a modal can fire.
+ * Contain the events the calendar component can fire.
  *
- * @module     core_calendar/calendar_events
+ * @module     core_calendar/events
  * @class      calendar_events
  * @package    core_calendar
  * @copyright  2017 Simey Lameze <simey@moodle.com>
  */
 define([], function() {
     return {
-        deleted: 'calendar-events:deleted'
+        created: 'calendar-events:created',
+        deleted: 'calendar-events:deleted',
+        updated: 'calendar-events:updated',
+        editEvent: 'calendar-events:edit_event',
+        editActionEvent: 'calendar-events:edit_action_event',
+        monthChanged: 'calendar-events:month_changed'
     };
 });
diff --git a/calendar/amd/src/modal_event_form.js b/calendar/amd/src/modal_event_form.js
new file mode 100644 (file)
index 0000000..bad5ca4
--- /dev/null
@@ -0,0 +1,433 @@
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Contain the logic for the quick add or update event modal.
+ *
+ * @module     calendar/modal_quick_add_event
+ * @class      modal_quick_add_event
+ * @package    core
+ * @copyright  2017 Ryan Wyllie <ryan@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+define([
+            'jquery',
+            'core/event',
+            'core/str',
+            'core/notification',
+            'core/templates',
+            'core/custom_interaction_events',
+            'core/modal',
+            'core/modal_registry',
+            'core/fragment',
+            'core_calendar/events',
+            'core_calendar/repository',
+            'core_calendar/event_form'
+        ],
+        function(
+            $,
+            Event,
+            Str,
+            Notification,
+            Templates,
+            CustomEvents,
+            Modal,
+            ModalRegistry,
+            Fragment,
+            CalendarEvents,
+            Repository,
+            EventForm
+        ) {
+
+    var registered = false;
+    var SELECTORS = {
+        MORELESS_BUTTON: '[data-action="more-less-toggle"]',
+        SAVE_BUTTON: '[data-action="save"]',
+        LOADING_ICON_CONTAINER: '[data-region="loading-icon-container"]',
+    };
+
+    /**
+     * Constructor for the Modal.
+     *
+     * @param {object} root The root jQuery element for the modal
+     */
+    var ModalEventForm = function(root) {
+        Modal.call(this, root);
+        this.eventId = null;
+        this.reloadingBody = false;
+        this.reloadingTitle = false;
+        this.saveButton = this.getFooter().find(SELECTORS.SAVE_BUTTON);
+        this.moreLessButton = this.getFooter().find(SELECTORS.MORELESS_BUTTON);
+    };
+
+    ModalEventForm.TYPE = 'core_calendar-modal_event_form';
+    ModalEventForm.prototype = Object.create(Modal.prototype);
+    ModalEventForm.prototype.constructor = ModalEventForm;
+
+    /**
+     * Set the event id to the given value.
+     *
+     * @method setEventId
+     * @param {int} id The event id
+     */
+    ModalEventForm.prototype.setEventId = function(id) {
+        this.eventId = id;
+    };
+
+    /**
+     * Retrieve the current event id, if any.
+     *
+     * @method getEventId
+     * @return {int|null} The event id
+     */
+    ModalEventForm.prototype.getEventId = function() {
+        return this.eventId;
+    };
+
+    /**
+     * Check if the modal has an event id.
+     *
+     * @method hasEventId
+     * @return {bool}
+     */
+    ModalEventForm.prototype.hasEventId = function() {
+        return this.eventId !== null;
+    };
+
+    /**
+     * Get the form element from the modal.
+     *
+     * @method getForm
+     * @return {object}
+     */
+    ModalEventForm.prototype.getForm = function() {
+        return this.getBody().find('form');
+    };
+
+    /**
+     * Disable the buttons in the footer.
+     *
+     * @method disableButtons
+     */
+    ModalEventForm.prototype.disableButtons = function() {
+        this.saveButton.prop('disabled', true);
+        this.moreLessButton.prop('disabled', true);
+    };
+
+    /**
+     * Enable the buttons in the footer.
+     *
+     * @method enableButtons
+     */
+    ModalEventForm.prototype.enableButtons = function() {
+        this.saveButton.prop('disabled', false);
+        this.moreLessButton.prop('disabled', false);
+    };
+
+    /**
+     * Set the more/less button in the footer to the "more"
+     * state.
+     *
+     * @method setMoreButton
+     */
+    ModalEventForm.prototype.setMoreButton = function() {
+        this.moreLessButton.attr('data-collapsed', 'true');
+        Str.get_string('more', 'calendar').then(function(string) {
+            this.moreLessButton.text(string);
+            return;
+        }.bind(this));
+    };
+
+    /**
+     * Set the more/less button in the footer to the "less"
+     * state.
+     *
+     * @method setLessButton
+     */
+    ModalEventForm.prototype.setLessButton = function() {
+        this.moreLessButton.attr('data-collapsed', 'false');
+        Str.get_string('less', 'calendar').then(function(string) {
+            this.moreLessButton.text(string);
+            return;
+        }.bind(this));
+    };
+
+    /**
+     * Toggle the more/less button in the footer from the current
+     * state to it's opposite state.
+     *
+     * @method toggleMoreLessButton
+     */
+    ModalEventForm.prototype.toggleMoreLessButton = function() {
+        var form = this.getForm();
+
+        if (this.moreLessButton.attr('data-collapsed') == 'true') {
+            form.trigger(EventForm.events.SHOW_ADVANCED);
+            this.setLessButton();
+        } else {
+            form.trigger(EventForm.events.HIDE_ADVANCED);
+            this.setMoreButton();
+        }
+    };
+
+    /**
+     * Reload the title for the modal to the appropriate value
+     * depending on whether we are creating a new event or
+     * editing an existing event.
+     *
+     * @method reloadTitleContent
+     * @return {object} A promise resolved with the new title text
+     */
+    ModalEventForm.prototype.reloadTitleContent = function() {
+        if (this.reloadingTitle) {
+            return this.titlePromise;
+        }
+
+        this.reloadingTitle = true;
+
+        if (this.hasEventId()) {
+            this.titlePromise = Str.get_string('editevent', 'calendar');
+        } else {
+            this.titlePromise = Str.get_string('newevent', 'calendar');
+        }
+
+        this.titlePromise.then(function(string) {
+            this.setTitle(string);
+            return string;
+        }.bind(this))
+        .always(function() {
+            this.reloadingTitle = false;
+            return;
+        }.bind(this));
+
+        return this.titlePromise;
+    };
+
+    /**
+     * Send a request to the server to get the event_form in a fragment
+     * and render the result in the body of the modal.
+     *
+     * If serialised form data is provided then it will be sent in the
+     * request to the server to have the form rendered with the data. This
+     * is used when the form had a server side error and we need the server
+     * to re-render it for us to display the error to the user.
+     *
+     * @method reloadBodyContent
+     * @param {string} formData The serialised form data
+     * @param {bool} hasError True if we know the form data is erroneous
+     * @return {object} A promise resolved with the fragment html and js from
+     */
+    ModalEventForm.prototype.reloadBodyContent = function(formData, hasError) {
+        if (this.reloadingBody) {
+            return this.bodyPromise;
+        }
+
+        this.reloadingBody = true;
+        this.disableButtons();
+
+        var contextId = this.saveButton.attr('data-context-id');
+        var args = {};
+
+        if (this.hasEventId()) {
+            args.eventid = this.getEventId();
+        }
+
+        if (typeof formData !== 'undefined') {
+            args.formdata = formData;
+        }
+
+        args.haserror = (typeof hasError == 'undefined') ? false : hasError;
+
+        this.bodyPromise = Fragment.loadFragment('calendar', 'event_form', contextId, args);
+
+        this.setBody(this.bodyPromise);
+
+        this.bodyPromise.then(function() {
+            this.enableButtons();
+            return;
+        }.bind(this))
+        .catch(Notification.exception)
+        .always(function() {
+            this.reloadingBody = false;
+            return;
+        }.bind(this));
+
+        return this.bodyPromise;
+    };
+
+    /**
+     * Reload both the title and body content.
+     *
+     * @method reloadAllContent
+     * @return {object} promise
+     */
+    ModalEventForm.prototype.reloadAllContent = function() {
+        return $.when(this.reloadTitleContent(), this.reloadBodyContent());
+    };
+
+    /**
+     * Kick off a reload the modal content before showing it. This
+     * is to allow us to re-use the same modal for creating and
+     * editing different events within the page.
+     *
+     * We do the reload when showing the modal rather than hiding it
+     * to save a request to the server if the user closes the modal
+     * and never re-opens it.
+     *
+     * @method show
+     */
+    ModalEventForm.prototype.show = function() {
+        this.reloadAllContent();
+        Modal.prototype.show.call(this);
+    };
+
+    /**
+     * Clear the event id from the modal when it's closed so
+     * that it is loaded fresh next time it's displayed.
+     *
+     * The event id will be set by the calling code if it wants
+     * to edit a specific event.
+     *
+     * @method hide
+     */
+    ModalEventForm.prototype.hide = function() {
+        Modal.prototype.hide.call(this);
+        this.setEventId(null);
+    };
+
+    /**
+     * Get the serialised form data.
+     *
+     * @method getFormData
+     * @return {string} serialised form data
+     */
+    ModalEventForm.prototype.getFormData = function() {
+        return this.getForm().serialize();
+    };
+
+    /**
+     * Send the form data to the server to create or update
+     * an event.
+     *
+     * If there is a server side validation error then we re-request the
+     * rendered form (with the data) from the server in order to get the
+     * server side errors to display.
+     *
+     * On success the modal is hidden and the page is reloaded so that the
+     * new event will display.
+     *
+     * @method save
+     * @return {object} A promise
+     */
+    ModalEventForm.prototype.save = function() {
+        var loadingContainer = this.saveButton.find(SELECTORS.LOADING_ICON_CONTAINER);
+
+        loadingContainer.removeClass('hidden');
+        this.disableButtons();
+
+        var formData = this.getFormData();
+        // Send the form data to the server for processing.
+        return Repository.submitCreateUpdateForm(formData)
+            .then(function(response) {
+                if (response.validationerror) {
+                    // If there was a server side validation error then
+                    // we need to re-request the rendered form from the server
+                    // in order to display the error for the user.
+                    return this.reloadBodyContent(formData, true);
+                } else {
+                    // No problemo! Our work here is done.
+                    this.hide();
+
+                    // Trigger the appropriate calendar event so that the view can
+                    // be updated.
+                    if (this.hasEventId()) {
+                        $('body').trigger(CalendarEvents.updated, [response.event]);
+                    } else {
+                        $('body').trigger(CalendarEvents.created, [response.event]);
+                    }
+                }
+
+                return;
+            }.bind(this))
+            .always(function() {
+                // Regardless of success or error we should always stop
+                // the loading icon and re-enable the buttons.
+                loadingContainer.addClass('hidden');
+                this.enableButtons();
+            }.bind(this))
+            .catch(Notification.exception);
+    };
+
+    /**
+     * Set up all of the event handling for the modal.
+     *
+     * @method registerEventListeners
+     */
+    ModalEventForm.prototype.registerEventListeners = function() {
+        // Apply parent event listeners.
+        Modal.prototype.registerEventListeners.call(this);
+
+        // When the user clicks the save button we trigger the form submission. We need to
+        // trigger an actual submission because there is some JS code in the form that is
+        // listening for this event and doing some stuff (e.g. saving draft areas etc).
+        this.getModal().on(CustomEvents.events.activate, SELECTORS.SAVE_BUTTON, function(e, data) {
+            this.getForm().submit();
+            data.originalEvent.preventDefault();
+            e.stopPropagation();
+        }.bind(this));
+
+        // Catch the submit event before it is actually processed by the browser and
+        // prevent the submission. We'll take it from here.
+        this.getModal().on('submit', function(e) {
+            this.save();
+
+            // Stop the form from actually submitting and prevent it's
+            // propagation because we have already handled the event.
+            e.preventDefault();
+            e.stopPropagation();
+        }.bind(this));
+
+        // Toggle the state of the more/less button in the footer.
+        this.getModal().on(CustomEvents.events.activate, SELECTORS.MORELESS_BUTTON, function(e, data) {
+            this.toggleMoreLessButton();
+
+            data.originalEvent.preventDefault();
+            e.stopPropagation();
+        }.bind(this));
+
+        // When the event form tells us that the advanced fields are shown
+        // then the more/less button should be set to less to allow the user
+        // to hide the advanced fields.
+        this.getModal().on(EventForm.events.ADVANCED_SHOWN, function() {
+            this.setLessButton();
+        }.bind(this));
+
+        // When the event form tells us that the advanced fields are hidden
+        // then the more/less button should be set to more to allow the user
+        // to show the advanced fields.
+        this.getModal().on(EventForm.events.ADVANCED_HIDDEN, function() {
+            this.setMoreButton();
+        }.bind(this));
+    };
+
+    // Automatically register with the modal registry the first time this module is imported so that you can create modals
+    // of this type using the modal factory.
+    if (!registered) {
+        ModalRegistry.register(ModalEventForm.TYPE, ModalEventForm, 'calendar/modal_event_form');
+        registered = true;
+    }
+
+    return ModalEventForm;
+});
similarity index 61%
rename from calendar/amd/src/calendar_repository.js
rename to calendar/amd/src/repository.js
index 2de4c41..5c95483 100644 (file)
@@ -16,7 +16,7 @@
 /**
  * A javascript module to handle calendar ajax actions.
  *
- * @module     core_calendar/calendar_repository
+ * @module     core_calendar/repository
  * @class      repository
  * @package    core_calendar
  * @copyright  2017 Simey Lameze <lameze@moodle.com>
@@ -65,8 +65,48 @@ define(['jquery', 'core/ajax'], function($, Ajax) {
         return Ajax.call([request])[0];
     };
 
+    /**
+     * Submit the form data for the event form.
+     *
+     * @method submitCreateUpdateForm
+     * @param {string} formdata The URL encoded values from the form
+     * @return {promise} Resolved with the new or edited event
+     */
+    var submitCreateUpdateForm = function(formdata) {
+        var request = {
+            methodname: 'core_calendar_submit_create_update_form',
+            args: {
+                formdata: formdata
+            }
+        };
+
+        return Ajax.call([request])[0];
+    };
+
+    /**
+     * Get calendar data for the month view.
+     *
+     * @method getCalendarMonthData
+     * @param {Number} time Timestamp.
+     * @param {Number} courseid The course id.
+     * @return {promise} Resolved with the month view data.
+     */
+    var getCalendarMonthData = function(time, courseid) {
+        var request = {
+            methodname: 'core_calendar_get_calendar_monthly_view',
+            args: {
+                time: time,
+                courseid: courseid
+            }
+        };
+
+        return Ajax.call([request])[0];
+    };
+
     return {
         getEventById: getEventById,
-        deleteEvent: deleteEvent
+        deleteEvent: deleteEvent,
+        submitCreateUpdateForm: submitCreateUpdateForm,
+        getCalendarMonthData: getCalendarMonthData
     };
 });
index e344d62..4a668be 100644 (file)
@@ -22,8 +22,8 @@
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
 define(['jquery', 'core/str', 'core/notification', 'core/custom_interaction_events', 'core/modal',
-    'core/modal_registry', 'core/modal_factory', 'core/modal_events', 'core_calendar/calendar_repository',
-    'core_calendar/calendar_events'],
+    'core/modal_registry', 'core/modal_factory', 'core/modal_events', 'core_calendar/repository',
+    'core_calendar/events'],
     function($, Str, Notification, CustomEvents, Modal, ModalRegistry, ModalFactory, ModalEvents, CalendarRepository,
              CalendarEvents) {
 
@@ -32,7 +32,6 @@ define(['jquery', 'core/str', 'core/notification', 'core/custom_interaction_even
         ROOT: "[data-region='summary-modal-container']",
         EDIT_BUTTON: '[data-action="edit"]',
         DELETE_BUTTON: '[data-action="delete"]',
-        EVENT_LINK: '[data-action="event-link"]'
     };
 
     /**
@@ -42,19 +41,73 @@ define(['jquery', 'core/str', 'core/notification', 'core/custom_interaction_even
      */
     var ModalEventSummary = function(root) {
         Modal.call(this, root);
+    };
+
+    ModalEventSummary.TYPE = 'core_calendar-event_summary';
+    ModalEventSummary.prototype = Object.create(Modal.prototype);
+    ModalEventSummary.prototype.constructor = ModalEventSummary;
 
-        if (!this.getFooter().find(SELECTORS.EDIT_BUTTON).length) {
-            Notification.exception({message: 'No edit button found'});
+    /**
+     * Get the edit button element from the footer. The button is cached
+     * as it's not expected to change.
+     *
+     * @method getEditButton
+     * @return {object} button element
+     */
+    ModalEventSummary.prototype.getEditButton = function() {
+        if (typeof this.editButton == 'undefined') {
+            this.editButton = this.getFooter().find(SELECTORS.EDIT_BUTTON);
         }
 
-        if (!this.getFooter().find(SELECTORS.DELETE_BUTTON).length) {
-            Notification.exception({message: 'No delete button found'});
+        return this.editButton;
+    };
+
+    /**
+     * Get the delete button element from the footer. The button is cached
+     * as it's not expected to change.
+     *
+     * @method getDeleteButton
+     * @return {object} button element
+     */
+    ModalEventSummary.prototype.getDeleteButton = function() {
+        if (typeof this.deleteButton == 'undefined') {
+            this.deleteButton = this.getFooter().find(SELECTORS.DELETE_BUTTON);
         }
+
+        return this.deleteButton;
     };
 
-    ModalEventSummary.TYPE = 'core_calendar-event_summary';
-    ModalEventSummary.prototype = Object.create(Modal.prototype);
-    ModalEventSummary.prototype.constructor = ModalEventSummary;
+    /**
+     * Get the id for the event being shown in this modal. This value is
+     * not cached because it will change depending on which event is
+     * being displayed.
+     *
+     * @method getEventId
+     * @return {int}
+     */
+    ModalEventSummary.prototype.getEventId = function() {
+        return this.getBody().find(SELECTORS.ROOT).attr('data-event-id');
+    };
+
+    /**
+     * Get the url for the event being shown in this modal.
+     *
+     * @method getEventUrl
+     * @return {String}
+     */
+    ModalEventSummary.prototype.getEditUrl = function() {
+        return this.getBody().find(SELECTORS.ROOT).attr('data-edit-url');
+    };
+
+    /**
+     * Is this an action event.
+     *
+     * @method getEventUrl
+     * @return {String}
+     */
+    ModalEventSummary.prototype.isActionEvent = function() {
+        return (this.getBody().find(SELECTORS.ROOT).attr('data-action-event') == 'true');
+    };
 
     /**
      * Set up all of the event handling for the modal.
@@ -64,28 +117,60 @@ define(['jquery', 'core/str', 'core/notification', 'core/custom_interaction_even
     ModalEventSummary.prototype.registerEventListeners = function() {
         // Apply parent event listeners.
         Modal.prototype.registerEventListeners.call(this);
-        var confirmPromise = ModalFactory.create({
-            type: ModalFactory.types.CONFIRM,
-        }, this.getFooter().find(SELECTORS.DELETE_BUTTON)).then(function(modal) {
-            Str.get_string('confirm').then(function(languagestring) {
-                modal.setTitle(languagestring);
-            }.bind(this)).catch(Notification.exception);
+
+        var confirmPromise = ModalFactory.create(
+            {
+                type: ModalFactory.types.CONFIRM
+            },
+            this.getDeleteButton()
+        ).then(function(modal) {
             modal.getRoot().on(ModalEvents.yes, function() {
-                var eventId = this.getBody().find(SELECTORS.ROOT).attr('data-event-id');
-                CalendarRepository.deleteEvent(eventId).done(function() {
-                    modal.getRoot().trigger(CalendarEvents.deleted, eventId);
-                    window.location.reload();
-                }).fail(Notification.exception);
+                var eventId = this.getEventId();
+
+                CalendarRepository.deleteEvent(eventId)
+                    .then(function() {
+                        $('body').trigger(CalendarEvents.deleted, [eventId]);
+                        this.hide();
+                    }.bind(this))
+                    .catch(Notification.exception);
             }.bind(this));
+
             return modal;
         }.bind(this));
 
+        // We have to wait for the modal to finish rendering in order to ensure that
+        // the data-event-title property is available to use as the modal title.
         this.getRoot().on(ModalEvents.bodyRendered, function() {
             var eventTitle = this.getBody().find(SELECTORS.ROOT).attr('data-event-title');
             confirmPromise.then(function(modal) {
                 modal.setBody(Str.get_string('confirmeventdelete', 'core_calendar', eventTitle));
             });
         }.bind(this));
+
+        CustomEvents.define(this.getEditButton(), [
+            CustomEvents.events.activate
+        ]);
+
+        this.getEditButton().on(CustomEvents.events.activate, function(e, data) {
+
+            if (this.isActionEvent()) {
+                // Action events cannot be edited on the event form and must be redirected to the module UI.
+                $('body').trigger(CalendarEvents.editActionEvent, [this.getEditUrl()]);
+            } else {
+                // When the edit button is clicked we fire an event for the calendar UI to handle.
+                // We don't care how the UI chooses to handle it.
+                $('body').trigger(CalendarEvents.editEvent, [this.getEventId()]);
+            }
+
+            // There is nothing else for us to do so let's hide.
+            this.hide();
+
+            // We've handled this event so no need to propagate it.
+            e.preventDefault();
+            e.stopPropagation();
+            data.originalEvent.preventDefault();
+            data.originalEvent.stopPropagation();
+        }.bind(this));
     };
 
     // Automatically register with the modal registry the first time this module is imported so that you can create modals
diff --git a/calendar/amd/src/view_manager.js b/calendar/amd/src/view_manager.js
new file mode 100644 (file)
index 0000000..10024f0
--- /dev/null
@@ -0,0 +1,109 @@
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * A javascript module to handler calendar view changes.
+ *
+ * @module     core_calendar/view_manager
+ * @package    core_calendar
+ * @copyright  2017 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+define(['jquery', 'core/templates', 'core/notification', 'core_calendar/repository', 'core_calendar/events'],
+    function($, Templates, Notification, CalendarRepository, CalendarEvents) {
+
+        var SELECTORS = {
+            ROOT: "[data-region='calendar']",
+            CALENDAR_NAV_LINK: "span.calendarwrapper .arrow_link",
+            CALENDAR_MONTH_WRAPPER: ".calendarwrapper",
+        };
+
+        /**
+         * Register event listeners for the module.
+         *
+         * @param {object} root The root element.
+         */
+        var registerEventListeners = function(root) {
+            root = $(root);
+
+            root.on('click', SELECTORS.CALENDAR_NAV_LINK, function(e) {
+                var courseId = $(root).find(SELECTORS.CALENDAR_MONTH_WRAPPER).data('courseid');
+                var link = $(e.currentTarget);
+                changeMonth(link.attr('href'), link.data('time'), courseId);
+
+                e.preventDefault();
+            });
+        };
+
+        /**
+         * Refresh the month content.
+         *
+         * @param {Number} time The calendar time to be shown
+         * @param {Number} courseid The id of the course whose events are shown
+         * @return {promise}
+         */
+        var refreshMonthContent = function(time, courseid) {
+            return CalendarRepository.getCalendarMonthData(time, courseid)
+                .then(function(context) {
+                    return Templates.render('core_calendar/month_detailed', context);
+                })
+                .then(function(html, js) {
+                    return Templates.replaceNodeContents(SELECTORS.CALENDAR_MONTH_WRAPPER, html, js);
+                })
+                .fail(Notification.exception);
+        };
+
+        /**
+         * Handle changes to the current calendar view.
+         *
+         * @param {String} url The calendar url to be shown
+         * @param {Number} time The calendar time to be shown
+         * @param {Number} courseid The id of the course whose events are shown
+         * @return {promise}
+         */
+        var changeMonth = function(url, time, courseid) {
+            return refreshMonthContent(time, courseid)
+                .then(function() {
+                    window.history.pushState({}, '', url);
+                    return arguments;
+                })
+                .then(function() {
+                    $('body').trigger(CalendarEvents.monthChanged, [time, courseid]);
+                    return arguments;
+                });
+        };
+
+        /**
+         * Reload the current month view data.
+         *
+         * @return {promise}
+         */
+        var reloadCurrentMonth = function() {
+            var root = $(SELECTORS.ROOT),
+                courseid = root.find(SELECTORS.CALENDAR_MONTH_WRAPPER).data('courseid'),
+                time = root.find(SELECTORS.CALENDAR_MONTH_WRAPPER).data('current-time');
+
+            return refreshMonthContent(time, courseid);
+        };
+
+        return {
+            init: function() {
+                registerEventListeners(SELECTORS.ROOT);
+            },
+            reloadCurrentMonth: reloadCurrentMonth,
+            changeMonth: changeMonth,
+            refreshMonthContent: refreshMonthContent
+        };
+    });
diff --git a/calendar/classes/external/calendar_event_exporter.php b/calendar/classes/external/calendar_event_exporter.php
new file mode 100644 (file)
index 0000000..3186390
--- /dev/null
@@ -0,0 +1,87 @@
+<?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/>.
+
+/**
+ * Contains event class for displaying a calendar event.
+ *
+ * @package   core_calendar
+ * @copyright 2017 Ryan Wyllie <ryan@moodle.com>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core_calendar\external;
+
+defined('MOODLE_INTERNAL') || die();
+
+use \core_course\external\course_summary_exporter;
+use \renderer_base;
+
+/**
+ * Class for displaying a calendar event.
+ *
+ * @package   core_calendar
+ * @copyright 2017 Ryan Wyllie <ryan@moodle.com>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class calendar_event_exporter extends event_exporter_base {
+
+    /**
+     * Return the list of additional properties.
+     *
+     * @return array
+     */
+    protected static function define_other_properties() {
+        return [
+            'url' => ['type' => PARAM_URL],
+            'icon' => [
+                'type' => event_icon_exporter::read_properties_definition(),
+            ],
+            'course' => [
+                'type' => course_summary_exporter::read_properties_definition(),
+                'optional' => true,
+            ]
+        ];
+    }
+
+    /**
+     * Get the additional values to inject while exporting.
+     *
+     * @param renderer_base $output The renderer.
+     * @return array Keys are the property names, values are their values.
+     */
+    protected function get_other_values(renderer_base $output) {
+        $values = parent::get_other_values($output);
+
+        $eventid = $this->event->get_id();
+
+        $url = new \moodle_url($this->related['daylink'], [], "event_{$eventid}");
+        $values['url'] = $url->out(false);
+
+        return $values;
+    }
+
+    /**
+     * Returns a list of objects that are related.
+     *
+     * @return array
+     */
+    protected static function define_related() {
+        $related = parent::define_related();
+        $related['daylink'] = \moodle_url::class;
+
+        return $related;
+    }
+}
diff --git a/calendar/classes/external/day_exporter.php b/calendar/classes/external/day_exporter.php
new file mode 100644 (file)
index 0000000..5cac152
--- /dev/null
@@ -0,0 +1,149 @@
+<?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/>.
+
+/**
+ * Contains event class for displaying the day view.
+ *
+ * @package   core_calendar
+ * @copyright 2017 Andrew Nicols <andrew@nicols.co.uk>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core_calendar\external;
+
+defined('MOODLE_INTERNAL') || die();
+
+use core\external\exporter;
+use renderer_base;
+use moodle_url;
+
+/**
+ * Class for displaying the day view.
+ *
+ * @package   core_calendar
+ * @copyright 2017 Andrew Nicols <andrew@nicols.co.uk>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class day_exporter extends exporter {
+
+    /**
+     * Return the list of properties.
+     *
+     * @return array
+     */
+    protected static function define_properties() {
+        // These are the default properties as returned by getuserdate()
+        // but without the formatted month and week names.
+        return [
+            'seconds' => [
+                'type' => PARAM_INT,
+            ],
+            'minutes' => [
+                'type' => PARAM_INT,
+            ],
+            'hours' => [
+                'type' => PARAM_INT,
+            ],
+            'mday' => [
+                'type' => PARAM_INT,
+            ],
+            'wday' => [
+                'type' => PARAM_INT,
+            ],
+            'year' => [
+                'type' => PARAM_INT,
+            ],
+            'yday' => [
+                'type' => PARAM_INT,
+            ],
+        ];
+    }
+
+    /**
+     * Return the list of additional properties.
+     *
+     * @return array
+     */
+    protected static function define_other_properties() {
+        return [
+            'timestamp' => [
+                'type' => PARAM_INT,
+            ],
+            'istoday' => [
+                'type' => PARAM_BOOL,
+                'default' => false,
+            ],
+            'isweekend' => [
+                'type' => PARAM_BOOL,
+                'default' => false,
+            ],
+            'viewdaylink' => [
+                'type' => PARAM_URL,
+                'optional' => true,
+            ],
+            'events' => [
+                'type' => calendar_event_exporter::read_properties_definition(),
+                'multiple' => true,
+            ]
+        ];
+    }
+
+    /**
+     * Get the additional values to inject while exporting.
+     *
+     * @param renderer_base $output The renderer.
+     * @return array Keys are the property names, values are their values.
+     */
+    protected function get_other_values(renderer_base $output) {
+        $return = [
+            'timestamp' => $this->data[0],
+        ];
+
+        $url = new moodle_url('/calendar/view.php', [
+                'view' => 'day',
+                'time' => $this->data[0],
+            ]);
+        $return['viewdaylink'] = $url->out(false);
+
+        $cache = $this->related['cache'];
+        $return['events'] = array_map(function($event) use ($cache, $output, $url) {
+            $context = $cache->get_context($event);
+            $course = $cache->get_course($event);
+            $exporter = new calendar_event_exporter($event, [
+                'context' => $context,
+                'course' => $course,
+                'daylink' => $url,
+            ]);
+
+            return $exporter->export($output);
+        }, $this->related['events']);
+
+        return $return;
+    }
+
+    /**
+     * Returns a list of objects that are related.
+     *
+     * @return array
+     */
+    protected static function define_related() {
+        return [
+            'events' => '\core_calendar\local\event\entities\event_interface[]',
+            'cache' => '\core_calendar\external\events_related_objects_cache',
+            'type' => '\core_calendar\type_base',
+        ];
+    }
+}
diff --git a/calendar/classes/external/day_name_exporter.php b/calendar/classes/external/day_name_exporter.php
new file mode 100644 (file)
index 0000000..41c657f
--- /dev/null
@@ -0,0 +1,87 @@
+<?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/>.
+
+/**
+ * Contains event class for displaying the day name.
+ *
+ * @package   core_calendar
+ * @copyright 2017 Andrew Nicols <andrew@nicols.co.uk>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core_calendar\external;
+
+defined('MOODLE_INTERNAL') || die();
+
+use core\external\exporter;
+
+/**
+ * Class for displaying the day view.
+ *
+ * @package   core_calendar
+ * @copyright 2017 Andrew Nicols <andrew@nicols.co.uk>
+ * @license  &n