Merge branch 'install_master' of https://git.in.moodle.com/amosbot/moodle-install
authorEloy Lafuente (stronk7) <stronk7@moodle.org>
Thu, 28 Feb 2019 17:51:31 +0000 (18:51 +0100)
committerEloy Lafuente (stronk7) <stronk7@moodle.org>
Thu, 28 Feb 2019 17:51:31 +0000 (18:51 +0100)
413 files changed:
admin/cli/install_database.php
admin/settings.php
admin/templates/setting.mustache
admin/templates/setting_configcolourpicker.mustache
admin/templates/setting_configduration.mustache
admin/templates/setting_configfile.mustache
admin/templates/setting_configmultiselect.mustache
admin/templates/setting_configmultiselect_optgroup.mustache
admin/templates/setting_configselect.mustache
admin/templates/setting_configselect_optgroup.mustache
admin/templates/setting_configtext.mustache
admin/templates/setting_configtextarea.mustache
admin/templates/setting_configtime.mustache
admin/templates/setting_courselist_frontpage.mustache
admin/templates/setting_description.mustache
admin/templates/setting_devicedetectregex.mustache
admin/templates/setting_emoticons.mustache
admin/templates/setting_gradecat_combo.mustache
admin/templates/settings.mustache
admin/templates/settings_search_results.mustache
admin/tests/behat/behat_admin.php
admin/tool/analytics/amd/build/model.min.js
admin/tool/analytics/amd/src/model.js
admin/tool/analytics/classes/output/form/edit_model.php
admin/tool/analytics/classes/output/form/import_model.php [new file with mode: 0644]
admin/tool/analytics/classes/output/helper.php
admin/tool/analytics/classes/output/models_list.php
admin/tool/analytics/createmodel.php [new file with mode: 0644]
admin/tool/analytics/importmodel.php [new file with mode: 0644]
admin/tool/analytics/lang/en/tool_analytics.php
admin/tool/analytics/model.php
admin/tool/analytics/templates/models_list.mustache
admin/tool/behat/tests/fixtures/core/behat_test_context_1.php
admin/tool/behat/tests/fixtures/core/behat_test_context_2.php
admin/tool/behat/tests/fixtures/theme/defaulttheme/behat_theme_defaulttheme_test_context_1.php
admin/tool/behat/tests/fixtures/theme/nofeatures/behat_theme_nofeatures_behat_test_context_2.php
admin/tool/behat/tests/fixtures/theme/nofeatures/behat_theme_nofeatures_test_context_1.php
admin/tool/behat/tests/fixtures/theme/withfeatures/behat_theme_withfeatures_behat_test_context_1.php
admin/tool/behat/tests/fixtures/theme/withfeatures/behat_theme_withfeatures_test_context_2.php
admin/tool/behat/tests/manager_util_test.php
admin/tool/behat/upgrade.txt
admin/tool/lp/templates/progress_bar.mustache
admin/tool/task/tests/behat/clear_fail_delay.feature
admin/tool/task/tests/behat/manage_tasks.feature
admin/tool/usertours/amd/build/managesteps.min.js
admin/tool/usertours/amd/build/managetours.min.js
admin/tool/usertours/amd/src/managesteps.js
admin/tool/usertours/amd/src/managetours.js
admin/tool/usertours/classes/helper.php
admin/tool/usertours/templates/tourstep.mustache
analytics/classes/calculable.php
analytics/classes/manager.php
analytics/classes/model.php
analytics/classes/model_config.php [new file with mode: 0644]
analytics/classes/packable.php [new file with mode: 0644]
analytics/tests/model_test.php
analytics/tests/prediction_test.php
backup/moodle2/restore_stepslib.php
badges/tests/behat/add_badge.feature
badges/tests/behat/award_badge.feature
badges/view.php
blocks/admin_bookmarks/tests/behat/bookmark_admin_pages.feature
blocks/myoverview/amd/build/view.min.js
blocks/myoverview/amd/src/view.js
blocks/myoverview/lang/en/block_myoverview.php
blocks/myoverview/templates/nav-grouping-selector.mustache
blocks/recentlyaccessedcourses/amd/build/main.min.js
blocks/recentlyaccessedcourses/amd/src/main.js
blocks/recentlyaccessedcourses/classes/output/main.php
blocks/recentlyaccessedcourses/templates/course-card.mustache [new file with mode: 0644]
blocks/recentlyaccessedcourses/templates/no-courses.mustache [deleted file]
blocks/recentlyaccessedcourses/templates/recentlyaccessedcourses-view.mustache
blocks/search_forums/templates/search_form.mustache
blocks/settings/renderer.php
blocks/settings/templates/search_form.mustache [moved from theme/boost/templates/block_settings/search_form.mustache with 100% similarity]
blocks/tests/behat/behat_blocks.php
calendar/templates/minicalendar_day_link.mustache
competency/classes/api.php
completion/classes/progress.php
course/classes/management_renderer.php
course/renderer.php
course/templates/course_search_form.mustache [moved from theme/boost/templates/course_search_form.mustache with 100% similarity]
course/templates/coursecard.mustache [new file with mode: 0644]
course/templates/coursecards.mustache
course/tests/behat/behat_course.php
enrol/self/tests/self_test.php
files/renderer.php
grade/grading/form/guide/templates/comment_chooser.mustache
grade/report/history/classes/output/renderer.php
grade/report/history/templates/user_button.mustache [moved from theme/boost/templates/gradereport_history/user_button.mustache with 100% similarity]
grade/report/singleview/templates/bulk_insert.mustache
grade/report/singleview/templates/button.mustache
grade/report/singleview/templates/dropdown_attribute.mustache
grade/report/singleview/templates/text_attribute.mustache
grade/tests/behat/behat_grade.php
grade/tests/importlib_test.php
grades/templates/edit_tree.mustache [moved from theme/boost/templates/core_grades/edit_tree.mustache with 100% similarity]
grades/templates/weight_field.mustache [moved from theme/boost/templates/core_grades/weight_field.mustache with 100% similarity]
grades/templates/weight_override_field.mustache [moved from theme/boost/templates/core_grades/weight_override_field.mustache with 100% similarity]
lang/en/admin.php
lang/en/analytics.php
lang/en/message.php
lib/adminlib.php
lib/amd/build/paged_content_paging_bar.min.js
lib/amd/build/showhidesettings.min.js [new file with mode: 0644]
lib/amd/src/paged_content_paging_bar.js
lib/amd/src/showhidesettings.js [new file with mode: 0644]
lib/behat/classes/behat_config_util.php
lib/behat/classes/behat_context_helper.php
lib/behat/core_behat_file_helper.php [moved from lib/behat/behat_files.php with 94% similarity]
lib/classes/component.php
lib/grade/grade_item.php
lib/mlbackend/php/classes/processor.php
lib/mlbackend/python/classes/processor.php
lib/navigationlib.php
lib/outputrenderers.php
lib/templates/action_menu.mustache
lib/templates/action_menu_link.mustache
lib/templates/action_menu_trigger.mustache
lib/templates/auth_digital_minor_page.mustache
lib/templates/auth_verify_age_location_page.mustache
lib/templates/availability_info.mustache
lib/templates/block.mustache [moved from theme/boost/templates/core/block.mustache with 100% similarity]
lib/templates/chooser.mustache
lib/templates/columns-1to1to1.mustache
lib/templates/columns-1to2.mustache
lib/templates/columns-2to1.mustache
lib/templates/custom_menu_item.mustache [moved from theme/boost/templates/core/custom_menu_item.mustache with 100% similarity]
lib/templates/dataformat_selector.mustache
lib/templates/filemanager_confirmdialog.mustache [moved from theme/boost/templates/core/filemanager_confirmdialog.mustache with 100% similarity]
lib/templates/filemanager_default_searchform.mustache [moved from theme/boost/templates/core/filemanager_default_searchform.mustache with 100% similarity]
lib/templates/filemanager_fileselect.mustache [moved from theme/boost/templates/core/filemanager_fileselect.mustache with 100% similarity]
lib/templates/filemanager_loginform.mustache [moved from theme/boost/templates/core/filemanager_loginform.mustache with 100% similarity]
lib/templates/filemanager_modal_generallayout.mustache [moved from theme/boost/templates/core/filemanager_modal_generallayout.mustache with 100% similarity]
lib/templates/filemanager_page_generallayout.mustache [moved from theme/boost/templates/core/filemanager_page_generallayout.mustache with 100% similarity]
lib/templates/filemanager_processexistingfile.mustache [moved from theme/boost/templates/core/filemanager_processexistingfile.mustache with 100% similarity]
lib/templates/filemanager_processexistingfilemultiple.mustache [moved from theme/boost/templates/core/filemanager_processexistingfilemultiple.mustache with 100% similarity]
lib/templates/filemanager_selectlayout.mustache [moved from theme/boost/templates/core/filemanager_selectlayout.mustache with 100% similarity]
lib/templates/filemanager_uploadform.mustache [moved from theme/boost/templates/core/filemanager_uploadform.mustache with 100% similarity]
lib/templates/form_autocomplete_input.mustache
lib/templates/form_autocomplete_selection.mustache
lib/templates/full_header.mustache [moved from theme/boost/templates/header.mustache with 100% similarity]
lib/templates/help_icon.mustache
lib/templates/initials_bar.mustache
lib/templates/loginform.mustache
lib/templates/modal.mustache
lib/templates/modal_backdrop.mustache
lib/templates/navbar.mustache [moved from theme/boost/templates/core/navbar.mustache with 100% similarity]
lib/templates/notification_error.mustache
lib/templates/notification_info.mustache
lib/templates/notification_success.mustache
lib/templates/notification_warning.mustache
lib/templates/preferences_groups.mustache [moved from theme/boost/templates/core/preferences_groups.mustache with 100% similarity]
lib/templates/progress_bar.mustache
lib/templates/select_time.mustache
lib/templates/settings_link_page.mustache
lib/templates/settings_link_page_single.mustache
lib/templates/signup_form_layout.mustache
lib/templates/single_button.mustache
lib/templates/skip_links.mustache
lib/templates/tabtree.mustache [moved from theme/boost/templates/core/tabtree.mustache with 100% similarity]
lib/tests/behat/behat_action_menu.php
lib/tests/behat/behat_deprecated.php
lib/tests/behat/behat_navigation.php
lib/tests/weblib_test.php
lib/upgrade.txt
lib/weblib.php
message/amd/build/message_area.min.js [deleted file]
message/amd/build/message_area_actions.min.js [deleted file]
message/amd/build/message_area_contacts.min.js [deleted file]
message/amd/build/message_area_events.min.js [deleted file]
message/amd/build/message_area_messages.min.js [deleted file]
message/amd/build/message_area_profile.min.js [deleted file]
message/amd/build/message_area_search.min.js [deleted file]
message/amd/build/message_area_tabs.min.js [deleted file]
message/amd/build/message_drawer.min.js
message/amd/build/message_drawer_router.min.js
message/amd/build/message_drawer_view_contact.min.js
message/amd/build/message_drawer_view_contacts.min.js
message/amd/build/message_drawer_view_contacts_section_contacts.min.js
message/amd/build/message_drawer_view_conversation.min.js
message/amd/build/message_drawer_view_group_info.min.js
message/amd/build/message_drawer_view_overview.min.js
message/amd/build/message_drawer_view_overview_section.min.js
message/amd/build/message_drawer_view_search.min.js
message/amd/build/message_drawer_view_settings.min.js
message/amd/build/message_preferences.min.js [deleted file]
message/amd/build/message_user_button.min.js [new file with mode: 0644]
message/amd/src/message_area.js [deleted file]
message/amd/src/message_area_actions.js [deleted file]
message/amd/src/message_area_contacts.js [deleted file]
message/amd/src/message_area_events.js [deleted file]
message/amd/src/message_area_messages.js [deleted file]
message/amd/src/message_area_profile.js [deleted file]
message/amd/src/message_area_search.js [deleted file]
message/amd/src/message_area_tabs.js [deleted file]
message/amd/src/message_drawer.js
message/amd/src/message_drawer_router.js
message/amd/src/message_drawer_view_contact.js
message/amd/src/message_drawer_view_contacts.js
message/amd/src/message_drawer_view_contacts_section_contacts.js
message/amd/src/message_drawer_view_conversation.js
message/amd/src/message_drawer_view_group_info.js
message/amd/src/message_drawer_view_overview.js
message/amd/src/message_drawer_view_overview_section.js
message/amd/src/message_drawer_view_search.js
message/amd/src/message_drawer_view_settings.js
message/amd/src/message_preferences.js [deleted file]
message/amd/src/message_user_button.js [new file with mode: 0644]
message/classes/api.php
message/classes/helper.php
message/classes/output/messagearea/contact.php
message/classes/output/messagearea/contacts.php
message/classes/output/messagearea/message.php
message/classes/output/messagearea/message_area.php
message/classes/output/messagearea/message_search_results.php [deleted file]
message/classes/output/messagearea/messages.php
message/classes/output/messagearea/profile.php
message/classes/output/messagearea/user_search_results.php
message/externallib.php
message/index.php
message/lib.php
message/templates/message_area.mustache [deleted file]
message/templates/message_area_contact.mustache [deleted file]
message/templates/message_area_contacts_area.mustache [deleted file]
message/templates/message_area_message.mustache [deleted file]
message/templates/message_area_message_search_results.mustache [deleted file]
message/templates/message_area_messages.mustache [deleted file]
message/templates/message_area_messages_area.mustache [deleted file]
message/templates/message_area_profile.mustache [deleted file]
message/templates/message_area_response.mustache [deleted file]
message/templates/message_area_user_search_results.mustache [deleted file]
message/templates/message_drawer.mustache
message/templates/message_drawer_view_conversation_body_message.mustache
message/templates/message_drawer_view_conversation_footer_content.mustache
message/templates/message_drawer_view_overview_body.mustache
message/templates/message_drawer_view_overview_footer.mustache [moved from message/templates/message_area_contacts.mustache with 56% similarity]
message/templates/message_drawer_view_overview_section.mustache
message/templates/message_index.mustache [new file with mode: 0644]
message/templates/message_preferences.mustache [deleted file]
message/templates/message_preferences_component.mustache [deleted file]
message/templates/message_preferences_notification_processor.mustache [deleted file]
message/tests/behat/message_admin_settings.feature [new file with mode: 0644]
message/tests/externallib_test.php
message/upgrade.txt
mod/assign/classes/task/cron_task.php [new file with mode: 0644]
mod/assign/db/tasks.php [new file with mode: 0644]
mod/assign/externallib.php
mod/assign/lang/en/assign.php
mod/assign/lib.php
mod/assign/locallib.php
mod/assign/templates/grading_actions.mustache
mod/assign/templates/grading_navigation.mustache
mod/assign/templates/grading_navigation_user_selector.mustache
mod/assign/tests/externallib_test.php
mod/assign/version.php
mod/assignment/lib.php
mod/data/lib.php
mod/feedback/amd/build/edit.min.js
mod/feedback/amd/src/edit.js
mod/forum/lib.php
mod/forum/templates/big_search_form.mustache
mod/forum/templates/quick_search_form.mustache
mod/lesson/locallib.php
mod/lti/lib.php
mod/quiz/accessrule/timelimit/rule.php
mod/quiz/accessrule/timelimit/tests/rule_test.php
mod/quiz/classes/task/legacy_quiz_accessrules_cron.php [moved from message/classes/output/renderer.php with 54% similarity]
mod/quiz/classes/task/legacy_quiz_reports_cron.php [moved from theme/boost/classes/output/gradereport_history_renderer.php with 58% similarity]
mod/quiz/classes/task/update_overdue_attempts.php [new file with mode: 0644]
mod/quiz/db/tasks.php [new file with mode: 0644]
mod/quiz/lang/en/quiz.php
mod/quiz/lib.php
mod/quiz/report/statistics/classes/task/quiz_statistics_cleanup.php [new file with mode: 0644]
mod/quiz/report/statistics/db/tasks.php [new file with mode: 0644]
mod/quiz/report/statistics/lang/en/quiz_statistics.php
mod/quiz/report/statistics/lib.php
mod/quiz/report/statistics/version.php
mod/quiz/tests/behat/behat_mod_quiz.php
mod/quiz/upgrade.txt
mod/quiz/version.php
mod/scorm/lib.php
mod/wiki/lib.php
mod/wiki/version.php
mod/workshop/lib.php
mod/workshop/mod_form.php
mod/workshop/tests/behat/grade_to_pass.feature
question/renderer.php
question/type/numerical/renderer.php
question/type/numerical/styles.css
repository/tests/behat/behat_filepicker.php
repository/upload/tests/behat/behat_repository_upload.php
theme/boost/amd/build/drawer.min.js
theme/boost/amd/src/drawer.js
theme/boost/classes/output/core/files_renderer.php [deleted file]
theme/boost/classes/output/core_renderer.php
theme/boost/classes/output/core_renderer_maintenance.php [deleted file]
theme/boost/scss/moodle/blocks.scss
theme/boost/scss/moodle/core.scss
theme/boost/scss/moodle/message.scss
theme/boost/style/moodle.css
theme/boost/templates/block_search_forums/search_form.mustache [deleted file]
theme/boost/templates/core/action_menu_trigger.mustache [deleted file]
theme/boost/templates/core/auth_digital_minor_page.mustache [deleted file]
theme/boost/templates/core/auth_verify_age_location_page.mustache [deleted file]
theme/boost/templates/core/availability_info.mustache [deleted file]
theme/boost/templates/core/help_icon.mustache [deleted file]
theme/boost/templates/core/loginform.mustache [deleted file]
theme/boost/templates/core/settings_link_page.mustache [deleted file]
theme/boost/templates/core/signup_form_layout.mustache [deleted file]
theme/boost/templates/core_admin/setting_configtime.mustache [deleted file]
theme/boost/templates/core_calendar/minicalendar_day_link.mustache [deleted file]
theme/boost/templates/mod_forum/quick_search_form.mustache [deleted file]
theme/boost/tests/behat/behat_theme_boost_behat_blocks.php [deleted file]
theme/boost/tests/behat/behat_theme_boost_behat_navigation.php [deleted file]
theme/boost/upgrade.txt
theme/boost/version.php
theme/bootstrapbase/less/moodle/admin.less
theme/bootstrapbase/less/moodle/blocks.less
theme/bootstrapbase/less/moodle/bs4-compat.less
theme/bootstrapbase/less/moodle/core.less
theme/bootstrapbase/less/moodle/forms.less
theme/bootstrapbase/less/moodle/message.less
theme/bootstrapbase/less/moodle/responsive.less
theme/bootstrapbase/renderers.php
theme/bootstrapbase/renderers/block_settings_renderer.php [moved from theme/boost/classes/output/block_settings_renderer.php with 60% similarity]
theme/bootstrapbase/renderers/core/course_renderer.php [moved from theme/boost/classes/output/core/course_renderer.php with 62% similarity]
theme/bootstrapbase/renderers/core/files_renderer.php [new file with mode: 0644]
theme/bootstrapbase/renderers/core_course/management/renderer.php [moved from theme/boost/classes/output/core_course/management/renderer.php with 56% similarity]
theme/bootstrapbase/renderers/core_question/bank_renderer.php [moved from theme/boost/classes/output/core_question/bank_renderer.php with 62% similarity]
theme/bootstrapbase/renderers/core_renderer.php
theme/bootstrapbase/renderers/gradereport_history_renderer.php [new file with mode: 0644]
theme/bootstrapbase/style/moodle.css
theme/bootstrapbase/templates/block_search_forums/search_form.mustache [new file with mode: 0644]
theme/bootstrapbase/templates/core/action_menu.mustache [moved from theme/boost/templates/core/action_menu.mustache with 55% similarity]
theme/bootstrapbase/templates/core/action_menu_item.mustache [moved from theme/boost/templates/core/action_menu_item.mustache with 100% similarity]
theme/bootstrapbase/templates/core/action_menu_link.mustache [moved from theme/boost/templates/core/action_menu_link.mustache with 88% similarity]
theme/bootstrapbase/templates/core/action_menu_trigger.mustache [moved from theme/boost/templates/core_admin/setting_configselect.mustache with 54% similarity]
theme/bootstrapbase/templates/core/auth_digital_minor_page.mustache [moved from theme/boost/templates/core_admin/setting_configduration.mustache with 54% similarity]
theme/bootstrapbase/templates/core/auth_verify_age_location_page.mustache [moved from theme/boost/templates/core_admin/setting_configfile.mustache with 57% similarity]
theme/bootstrapbase/templates/core/chooser.mustache [moved from theme/boost/templates/core/chooser.mustache with 94% similarity]
theme/bootstrapbase/templates/core/columns-1to1to1.mustache [moved from theme/boost/templates/core/columns-1to1to1.mustache with 86% similarity]
theme/bootstrapbase/templates/core/columns-1to2.mustache [moved from theme/boost/templates/core/columns-1to2.mustache with 88% similarity]
theme/bootstrapbase/templates/core/columns-2to1.mustache [moved from theme/boost/templates/core/columns-2to1.mustache with 88% similarity]
theme/bootstrapbase/templates/core/dataformat_selector.mustache [moved from theme/boost/templates/core/dataformat_selector.mustache with 68% similarity]
theme/bootstrapbase/templates/core/form_autocomplete_input.mustache [moved from theme/boost/templates/core/form_autocomplete_input.mustache with 83% similarity]
theme/bootstrapbase/templates/core/form_autocomplete_selection.mustache [moved from theme/boost/templates/core/form_autocomplete_selection.mustache with 83% similarity]
theme/bootstrapbase/templates/core/help_icon.mustache [moved from theme/boost/templates/core_admin/setting_configmultiselect_optgroup.mustache with 52% similarity]
theme/bootstrapbase/templates/core/initials_bar.mustache [moved from theme/boost/templates/core/initials_bar.mustache with 75% similarity]
theme/bootstrapbase/templates/core/loginform.mustache [new file with mode: 0644]
theme/bootstrapbase/templates/core/modal.mustache [moved from theme/boost/templates/core/modal.mustache with 53% similarity]
theme/bootstrapbase/templates/core/modal_backdrop.mustache [moved from theme/boost/templates/core/modal_backdrop.mustache with 91% similarity]
theme/bootstrapbase/templates/core/notification_error.mustache [moved from theme/boost/templates/core/notification_error.mustache with 84% similarity]
theme/bootstrapbase/templates/core/notification_info.mustache [moved from theme/boost/templates/core/notification_info.mustache with 88% similarity]
theme/bootstrapbase/templates/core/notification_success.mustache [moved from theme/boost/templates/core/notification_success.mustache with 88% similarity]
theme/bootstrapbase/templates/core/notification_warning.mustache [moved from theme/boost/templates/core/notification_warning.mustache with 88% similarity]
theme/bootstrapbase/templates/core/progress_bar.mustache [moved from theme/boost/templates/core/progress_bar.mustache with 67% similarity]
theme/bootstrapbase/templates/core/select_time.mustache [moved from theme/boost/templates/core/select_time.mustache with 92% similarity]
theme/bootstrapbase/templates/core/settings_link_page.mustache [new file with mode: 0644]
theme/bootstrapbase/templates/core/settings_link_page_single.mustache [moved from theme/boost/templates/core/settings_link_page_single.mustache with 96% similarity]
theme/bootstrapbase/templates/core/signup_form_layout.mustache [new file with mode: 0644]
theme/bootstrapbase/templates/core/single_button.mustache [moved from theme/boost/templates/core/single_button.mustache with 97% similarity]
theme/bootstrapbase/templates/core/skip_links.mustache [moved from theme/boost/templates/core/skip_links.mustache with 60% similarity]
theme/bootstrapbase/templates/core_admin/setting.mustache [moved from theme/boost/templates/core_admin/setting.mustache with 50% similarity]
theme/bootstrapbase/templates/core_admin/setting_configcolourpicker.mustache [moved from theme/boost/templates/core_admin/setting_configcolourpicker.mustache with 67% similarity]
theme/bootstrapbase/templates/core_admin/setting_configduration.mustache [new file with mode: 0644]
theme/bootstrapbase/templates/core_admin/setting_configfile.mustache [new file with mode: 0644]
theme/bootstrapbase/templates/core_admin/setting_configmultiselect.mustache [moved from theme/boost/templates/core_admin/setting_configmultiselect.mustache with 60% similarity]
theme/bootstrapbase/templates/core_admin/setting_configmultiselect_optgroup.mustache [new file with mode: 0644]
theme/bootstrapbase/templates/core_admin/setting_configselect.mustache [moved from theme/boost/templates/core_admin/setting_configselect_optgroup.mustache with 62% similarity]
theme/bootstrapbase/templates/core_admin/setting_configselect_optgroup.mustache [new file with mode: 0644]
theme/bootstrapbase/templates/core_admin/setting_configtext.mustache [moved from theme/boost/templates/core_admin/setting_configtext.mustache with 52% similarity]
theme/bootstrapbase/templates/core_admin/setting_configtextarea.mustache [moved from theme/boost/templates/core_admin/setting_configtextarea.mustache with 56% similarity]
theme/bootstrapbase/templates/core_admin/setting_configtime.mustache [new file with mode: 0644]
theme/bootstrapbase/templates/core_admin/setting_courselist_frontpage.mustache [moved from theme/boost/templates/core_admin/setting_courselist_frontpage.mustache with 57% similarity]
theme/bootstrapbase/templates/core_admin/setting_description.mustache [moved from theme/boost/templates/core_admin/setting_description.mustache with 85% similarity]
theme/bootstrapbase/templates/core_admin/setting_devicedetectregex.mustache [moved from theme/boost/templates/core_admin/setting_devicedetectregex.mustache with 69% similarity]
theme/bootstrapbase/templates/core_admin/setting_emoticons.mustache [moved from theme/boost/templates/core_admin/setting_emoticons.mustache with 80% similarity]
theme/bootstrapbase/templates/core_admin/setting_gradecat_combo.mustache [moved from theme/boost/templates/core_admin/setting_gradecat_combo.mustache with 69% similarity]
theme/bootstrapbase/templates/core_admin/settings.mustache [moved from theme/boost/templates/core_admin/settings.mustache with 62% similarity]
theme/bootstrapbase/templates/core_admin/settings_search_results.mustache [moved from theme/boost/templates/core_admin/settings_search_results.mustache with 91% similarity]
theme/bootstrapbase/templates/core_message/message_drawer.mustache
theme/bootstrapbase/templates/core_message/message_drawer_view_conversation_body_message.mustache
theme/bootstrapbase/templates/core_message/message_drawer_view_conversation_footer_content.mustache
theme/bootstrapbase/templates/core_message/message_drawer_view_overview_section.mustache
theme/bootstrapbase/templates/core_message/message_index.mustache [new file with mode: 0644]
theme/bootstrapbase/templates/gradereport_singleview/bulk_insert.mustache [moved from theme/boost/templates/gradereport_singleview/bulk_insert.mustache with 92% similarity]
theme/bootstrapbase/templates/gradereport_singleview/button.mustache [moved from theme/boost/templates/gradereport_singleview/button.mustache with 89% similarity]
theme/bootstrapbase/templates/gradereport_singleview/dropdown_attribute.mustache [moved from theme/boost/templates/gradereport_singleview/dropdown_attribute.mustache with 89% similarity]
theme/bootstrapbase/templates/gradereport_singleview/text_attribute.mustache [moved from theme/boost/templates/gradereport_singleview/text_attribute.mustache with 89% similarity]
theme/bootstrapbase/templates/gradingform_guide/comment_chooser.mustache [moved from theme/boost/templates/gradingform_guide/comment_chooser.mustache with 87% similarity]
theme/bootstrapbase/templates/mod_assign/grading_actions.mustache [moved from theme/boost/templates/mod_assign/grading_actions.mustache with 75% similarity]
theme/bootstrapbase/templates/mod_assign/grading_navigation.mustache [moved from theme/boost/templates/mod_assign/grading_navigation.mustache with 90% similarity]
theme/bootstrapbase/templates/mod_assign/grading_navigation_user_selector.mustache [moved from theme/boost/templates/mod_assign/grading_navigation_user_selector.mustache with 57% similarity]
theme/bootstrapbase/templates/mod_forum/big_search_form.mustache [moved from theme/boost/templates/mod_forum/big_search_form.mustache with 64% similarity]
theme/bootstrapbase/templates/mod_forum/quick_search_form.mustache [new file with mode: 0644]
theme/bootstrapbase/templates/tool_lp/progress_bar.mustache [moved from theme/boost/templates/tool_lp/progress_bar.mustache with 69% similarity]
theme/bootstrapbase/templates/tool_usertours/tourstep.mustache [moved from theme/boost/templates/tool_usertours/tourstep.mustache with 66% similarity]
theme/bootstrapbase/tests/behat/behat_theme_bootstrapbase_behat_action_menu.php [moved from theme/boost/tests/behat/behat_theme_boost_behat_action_menu.php with 71% similarity]
theme/bootstrapbase/tests/behat/behat_theme_bootstrapbase_behat_admin.php [moved from theme/boost/tests/behat/behat_theme_boost_behat_admin.php with 68% similarity]
theme/bootstrapbase/tests/behat/behat_theme_bootstrapbase_behat_blocks.php [new file with mode: 0644]
theme/bootstrapbase/tests/behat/behat_theme_bootstrapbase_behat_course.php [moved from theme/boost/tests/behat/behat_theme_boost_behat_course.php with 68% similarity]
theme/bootstrapbase/tests/behat/behat_theme_bootstrapbase_behat_deprecated.php [moved from theme/boost/tests/behat/behat_theme_boost_behat_deprecated.php with 59% similarity]
theme/bootstrapbase/tests/behat/behat_theme_bootstrapbase_behat_filepicker.php [moved from theme/boost/tests/behat/behat_theme_boost_behat_filepicker.php with 57% similarity]
theme/bootstrapbase/tests/behat/behat_theme_bootstrapbase_behat_grade.php [moved from theme/boost/tests/behat/behat_theme_boost_behat_grade.php with 72% similarity]
theme/bootstrapbase/tests/behat/behat_theme_bootstrapbase_behat_mod_quiz.php [moved from theme/boost/tests/behat/behat_theme_boost_behat_mod_quiz.php with 79% similarity]
theme/bootstrapbase/tests/behat/behat_theme_bootstrapbase_behat_navigation.php [new file with mode: 0644]
theme/bootstrapbase/tests/behat/behat_theme_bootstrapbase_behat_repository_upload.php [moved from theme/boost/tests/behat/behat_theme_boost_behat_repository_upload.php with 74% similarity]
theme/bootstrapbase/tests/behat/blacklist.json [new file with mode: 0644]
theme/bootstrapbase/tests/behat/theme_bootstrapbase_behat_file_helper.php [moved from theme/boost/tests/behat/behat_theme_boost_behat_files.php with 73% similarity]
theme/bootstrapbase/upgrade.txt
theme/bootstrapbase/version.php
theme/upgrade.txt

index 0dd6122..8b5fe93 100644 (file)
@@ -82,11 +82,6 @@ require_once($CFG->libdir.'/installlib.php');
 require_once($CFG->libdir.'/adminlib.php');
 require_once($CFG->libdir.'/componentlib.class.php');
 
-// make sure no tables are installed yet
-if ($DB->get_tables() ) {
-    cli_error(get_string('clitablesexist', 'install'));
-}
-
 $CFG->early_install_lang = true;
 get_string_manager(true);
 
@@ -109,12 +104,17 @@ list($options, $unrecognized) = cli_get_params(
     )
 );
 
-
+// We show help text even if tables are installed.
 if ($options['help']) {
     echo $help;
     die;
 }
 
+// Make sure no tables are installed yet.
+if ($DB->get_tables() ) {
+    cli_error(get_string('clitablesexist', 'install'));
+}
+
 if (!$options['agree-license']) {
     cli_error('You have to agree to the license. --help prints out the help'); // TODO: localize
 }
index ecd2455..0872e03 100644 (file)
@@ -156,4 +156,11 @@ $PAGE->requires->yui_module('moodle-core-formchangechecker',
 );
 $PAGE->requires->string_for_js('changesmadereallygoaway', 'moodle');
 
+if ($settingspage->has_dependencies()) {
+    $opts = [
+        'dependencies' => $settingspage->get_dependencies_for_javascript()
+    ];
+    $PAGE->requires->js_call_amd('core/showhidesettings', 'init', [$opts]);
+}
+
 echo $OUTPUT->footer();
index ed17246..af78e7e 100644 (file)
@@ -29,6 +29,7 @@
     * element - The Element HTML
     * forceltr - Force this element to be displayed LTR
     * default - Default value
+    * dependenton - optional message listing the settings this one is dependent on
 
     Example context (json):
     {
         "default": "Default value"
     }
 }}
-<div class="form-item clearfix" id="{{id}}">
-    <div class="form-label">
+{{!
+    Setting.
+}}
+<div class="form-item row" id="{{id}}">
+    <div class="form-label col-sm-3 text-sm-right">
         <label {{#labelfor}}for="{{labelfor}}"{{/labelfor}}>
             {{{title}}}
             {{#override}}
                 <div class="form-warning">{{warning}}</div>
             {{/warning}}
         </label>
-        <span class="form-shortname">{{{name}}}</span>
+        <span class="form-shortname d-block small text-muted">{{{name}}}</span>
     </div>
-    <div class="form-setting">
+    <div class="form-setting col-sm-9">
         {{#error}}
             <div><span class="error">{{error}}</span></div>
         {{/error}}
         {{{element}}}
         {{#default}}
-            <div class="form-defaultinfo {{#forceltr}}text-ltr{{/forceltr}}">{{{default}}}</div>
+            <div class="form-defaultinfo text-muted {{#forceltr}}text-ltr{{/forceltr}}">{{{default}}}</div>
         {{/default}}
+        <div class="form-description mt-3">{{{description}}}</div>
+        {{#dependenton}}<div class="form-dependenton mb-4 text-muted">{{{.}}}</div>{{/dependenton}}
     </div>
-    <div class="form-description">{{{description}}}</div>
 </div>
index eb8c487..3de5aac 100644 (file)
         "haspreviewconfig": false
     }
 }}
+{{!
+    Setting configcolourpicker.
+}}
 <div class="form-colourpicker defaultsnext">
     <div class="admin_colourpicker clearfix">
         {{#icon}}
             {{>core/pix_icon}}
         {{/icon}}
     </div>
-    <input type="text" name="{{name}}" id="{{id}}" value="{{value}}" size="12" class="text-ltr">
+    <input type="text" name="{{name}}" id="{{id}}" value="{{value}}" size="12" class="form-control text-ltr">
     {{#haspreviewconfig}}
         <input type="button" id="{{id}}_preview" value={{#quote}}{{#str}}preview{{/str}}{{/quote}} class="admin_colourpicker_preview">
     {{/haspreviewconfig}}
index 3bad98c..d2faa4a 100644 (file)
         "options": [ { "name": "Minutes", "value": "mins", "selected": true } ]
     }
 }}
+{{!
+    Setting configduration.
+}}
 <div class="form-duration defaultsnext">
-    <input type="text" size="5" id="{{id}}v" name="{{name}}[v]" value="{{value}}" class="text-ltr">
-    <label class="accesshide" for="{{id}}u">{{#str}}durationunits, admin{{/str}}</label>
-    <select id="{{id}}u" name="{{name}}[u]">
-        {{#options}}
-            <option value="{{value}}" {{#selected}}selected{{/selected}}>{{name}}</option>
-        {{/options}}
-    </select>
+    <div class="form-inline">
+        <input type="text" size="5" id="{{id}}v" name="{{name}}[v]" value="{{value}}" class="form-control text-ltr">
+        <label class="sr-only" for="{{id}}u">{{#str}}durationunits, admin{{/str}}</label>
+        <select id="{{id}}u" name="{{name}}[u]" class="form-control">
+            {{#options}}
+                <option value="{{value}}" {{#selected}}selected{{/selected}}>{{name}}</option>
+            {{/options}}
+        </select>
+    </div>
 </div>
 
index f249fe6..1f0b5d5 100644 (file)
         "valid": false
     }
 }}
+{{!
+    Setting configfile.
+}}
 <div class="form-file defaultsnext">
-    <input type="text" name="{{name}}" id="{{id}}" size="{{size}}" value="{{value}}" class="text-ltr" {{#readonly}}readonly{{/readonly}}>
-    {{#showvalidity}}
-        {{#valid}}
-            <span class="pathok">&#x2714;</span>
-        {{/valid}}
-        {{^valid}}
-            <span class="patherror">&#x2718;</span>
-        {{/valid}}
-    {{/showvalidity}}
+    <div class="form-inline">
+        <input type="text" name="{{name}}" id="{{id}}" size="{{size}}" value="{{value}}" class="form-control text-ltr" {{#readonly}}readonly{{/readonly}}>
+        {{#showvalidity}}
+            {{#valid}}
+                <span class="text-success">&#x2714;</span>
+            {{/valid}}
+            {{^valid}}
+                <span class="text-danger">&#x2718;</span>
+            {{/valid}}
+        {{/showvalidity}}
+    </div>
 </div>
 
index 7465a7b..49eb55c 100644 (file)
                      { "name": "Option 2", "value": "V", "selected": true } ]
     }
 }}
+{{!
+    Setting configmultiselect.
+}}
 <div class="form-select">
     <input type="hidden" name="{{name}}[xxxxx]" value="1">
-    <select id="{{id}}" name="{{name}}[]" size="{{size}}" multiple>
+    <select id="{{id}}" name="{{name}}[]" size="{{size}}" class="form-control" multiple>
         {{#options}}
             <option value="{{value}}" {{#selected}}selected{{/selected}}>{{name}}</option>
         {{/options}}
index 9fa61c5..20d66e1 100644 (file)
         ]
     }
 }}
+{{!
+    Setting configmultiselect with optgroup support.
+}}
 <div class="form-select">
     <input type="hidden" name="{{name}}[xxxxx]" value="1">
-    <select id="{{id}}" name="{{name}}[]" size="{{size}}" multiple>
-        {{#options}}
-            <option value="{{value}}" {{#selected}}selected{{/selected}}>{{name}}</option>
-        {{/options}}
-        {{#optgroups}}
+    <select id="{{id}}" name="{{name}}[]" size="{{size}}" class="form-control" multiple>
+    {{#options}}
+        <option value="{{value}}" {{#selected}}selected{{/selected}}>{{name}}</option>
+    {{/options}}
+    {{#optgroups}}
             <optgroup label="{{label}}">
                 {{#options}}
                     <option value="{{value}}" {{#selected}}selected{{/selected}}>{{name}}</option>
index a90c260..9b6c6e1 100644 (file)
         ]
     }
 }}
+{{!
+    Setting configselect.
+}}
 <div class="form-select defaultsnext">
-    <select id="{{id}}" name="{{name}}">
+    <select id="{{id}}" name="{{name}}" class="custom-select">
         {{#options}}
             <option value="{{value}}" {{#selected}}selected{{/selected}}>{{name}}</option>
         {{/options}}
index ea5da12..95f818b 100644 (file)
         ]
     }
 }}
+{{!
+    Setting configselect with optgroup support.
+}}
 <div class="form-select defaultsnext">
-    <select id="{{id}}" name="{{name}}">
+    <select id="{{id}}" name="{{name}}" class="custom-select">
         {{#options}}
             <option value="{{value}}" {{#selected}}selected{{/selected}}>{{name}}</option>
         {{/options}}
@@ -66,3 +69,4 @@
     </select>
 </div>
 
+
index 236228a..a31f73a 100644 (file)
@@ -37,6 +37,9 @@
         "attributes": [ { "name": "readonly", "value": "readonly" } ]
     }
 }}
+{{!
+    Setting configtext.
+}}
 <div class="form-text defaultsnext">
-    <input type="text" name="{{name}}" value="{{value}}" size="{{size}}" id="{{id}}" class="{{#forceltr}}text-ltr{{/forceltr}}"{{#attributes}} {{name}}="{{value}}"{{/attributes}}>
+    <input type="text" name="{{name}}" value="{{value}}" size="{{size}}" id="{{id}}" class="form-control {{#forceltr}}text-ltr{{/forceltr}}">
 </div>
index ec6ded3..002b0cd 100644 (file)
@@ -36,6 +36,9 @@
         "id": "test0"
     }
 }}
+{{!
+    Setting configtextarea.
+}}
 <div class="form-textarea">
-    <textarea rows="{{rows}}" cols="{{cols}}" id="{{id}}" name="{{name}}" spellcheck="true" class="{{#forceltr}}text-ltr{{/forceltr}}">{{value}}</textarea>
+    <textarea rows="{{rows}}" cols="{{cols}}" id="{{id}}" name="{{name}}" spellcheck="true" class="form-control {{#forceltr}}text-ltr{{/forceltr}}">{{value}}</textarea>
 </div>
index b86b691..90d37b3 100644 (file)
         ]
     }
 }}
-<div class="form-time defaultsnext text-ltr">
-    <label class="accesshide" for="{{id}}h">{{#str}}hours{{/str}}</label>
-    <select id="{{id}}h" name="{{name}}[h]">
-        {{#hours}}
-            <option value="{{value}}" {{#selected}}selected{{/selected}}>{{name}}</option>
-        {{/hours}}
-    </select>:
-    <label class="accesshide" for="{{id}}m">{{#str}}minutes{{/str}}</label>
-    <select id="{{id}}m" name="{{name}}[m]">
-        {{#minutes}}
-            <option value="{{value}}" {{#selected}}selected{{/selected}}>{{name}}</option>
-        {{/minutes}}
-    </select>
+{{!
+    Setting configtime.
+}}
+<div class="form-time defaultsnext">
+    <div class="form-inline text-ltr">
+        <label class="sr-only" for="{{id}}h">{{#str}}hours{{/str}}</label>
+        <select id="{{id}}h" name="{{name}}[h]" class="custom-select">
+            {{#hours}}
+                <option value="{{value}}" {{#selected}}selected{{/selected}}>{{name}}</option>
+            {{/hours}}
+        </select>:
+        <label class="sr-only" for="{{id}}m">{{#str}}minutes{{/str}}</label>
+        <select id="{{id}}m" name="{{name}}[m]" class="custom-select">
+            {{#minutes}}
+                <option value="{{value}}" {{#selected}}selected{{/selected}}>{{name}}</option>
+            {{/minutes}}
+        </select>
+    </div>
 </div>
 
index 2fe18cc..ef89725 100644 (file)
         ]
     }
 }}
+{{!
+    Setting courselist_frontpage.
+}}
 <div class="form-group">
     {{#selects}}
-        <select id="{{id}}{{key}}" name="{{name}}[]" class="form-select">
+        <select id="{{id}}{{key}}" name="{{name}}[]" class="custom-select">
             {{#options}}
                 <option value="{{value}}" {{#selected}}selected{{/selected}}>{{name}}</option>
             {{/options}}
index c6d0e57..09bda33 100644 (file)
 {{!
     Setting description.
 }}
-<div class="form-item form-horizontal clearfix">
-    <div class="form-label">
+<div class="form-item row">
+    <div class="form-label col-sm-3 text-sm-right">
         <label>
             {{{title}}}
         </label>
-        <span class="form-shortname ">{{{name}}}</span>
     </div>
-    <div class="controls felement fstatic">{{{description}}}</div>
-</div>
\ No newline at end of file
+    <div class="form-setting col-sm-9">
+        <div class="form-description">{{{description}}}</div>
+    </div>
+</div>
index cc88862..97f97f2 100644 (file)
         ]
     }
 }}
-<table class="generaltable">
+{{!
+    Setting devicedetectregex.
+}}
+<table class="table table-striped">
     <thead>
         <tr>
             <th>{{#str}}devicedetectregexexpression, admin{{/str}}</th>
         {{#expressions}}
             <tr>
                 <td class="c{{index}}">
-                    <input type="text" name="{{name}}[expression{{index}}]" class="form-text text-ltr" value="{{expression}}">
+                    <input type="text" name="{{name}}[expression{{index}}]" class="form-control" value="{{expression}}">
                 </td>
                 <td class="c{{index}}">
-                    <input type="text" name="{{name}}[value{{index}}]" class="form-text text-ltr" value="{{value}}">
+                    <input type="text" name="{{name}}[value{{index}}]" class="form-control" value="{{value}}">
                 </td>
             </tr>
         {{/expressions}}
index 4c36174..8a245dd 100644 (file)
@@ -32,6 +32,9 @@
         ]
     }
 }}
+{{!
+    Setting emoticons.
+}}
 <div class="form-group">
     <table id="emoticonsetting" class="admintable generaltable">
         <thead>
@@ -48,7 +51,7 @@
                 <tr>
                     {{#fields}}
                         <td class="c{{index}}">
-                            <input type="text" name="{{name}}[{{field}}]" class="form-text text-ltr" value="{{value}}">
+                            <input type="text" name="{{name}}[{{field}}]" class="form-text form-control text-ltr" value="{{value}}">
                         </td>
                     {{/fields}}
                     <td>
index d64d75f..9e71c27 100644 (file)
         "advanced": true
     }
 }}
+{{!
+    Setting configselect.
+}}
 <div class="form-group">
-    <select id="{{id}}" name="{{name}}[value]" class="form-select">
+    <select id="{{id}}" name="{{name}}[value]" class="form-select custom-select">
         {{#options}}
             <option value="{{value}}" {{#selected}}selected{{/selected}}>{{name}}</option>
         {{/options}}
index 0097305..0067f8e 100644 (file)
         "showsave": true
     }
 }}
+{{!
+    Settings.
+}}
 <form action="{{actionurl}}" method="post" id="adminsettings">
-    <div class="settingsform clearfix">
+    <div class="settingsform">
         {{#params}}
             <input type="hidden" name="{{name}}" value="{{value}}">
             <input type="hidden" name="action" value="save-settings">
         {{/title}}
         {{{settings}}}
         {{#showsave}}
-            <div class="form-buttons">
-                <input type="submit" class="form-submit" value={{#quote}}{{#str}}savechanges, admin{{/str}}{{/quote}}>
+            <div class="row">
+                <div class="offset-sm-3 col-sm-3">
+                    <button type="submit" class="btn btn-primary">{{#str}}savechanges, admin{{/str}}</button>
+                </div>
             </div>
         {{/showsave}}
     </div>
index df82b0f..6653154 100644 (file)
                 </fieldset>
             {{/results}}
             {{#showsave}}
-                <div class="form-buttons">
-                    <input type="submit" class="form-submit" value={{#quote}}{{#str}}savechanges, admin{{/str}}{{/quote}}>
+                <div class="row">
+                    <div class="offset-sm-3 col-sm-3">
+                        <button type="submit" class="btn btn-primary">{{#str}}savechanges, admin{{/str}}</button>
+                    </div>
                 </div>
             {{/showsave}}
         {{/hasresults}}
index 73ce572..b2fd070 100644 (file)
@@ -55,16 +55,12 @@ class behat_admin extends behat_base {
 
         foreach ($data as $label => $value) {
 
-            // We expect admin block to be visible, otherwise go to homepage.
-            if (!$this->getSession()->getPage()->find('css', '.block_settings')) {
-                $this->getSession()->visit($this->locate_path('/'));
-                $this->wait(self::TIMEOUT * 1000, self::PAGE_READY_JS);
-            }
+            $this->execute('behat_navigation::i_select_from_flat_navigation_drawer', [get_string('administrationsite')]);
 
             // Search by label.
-            $searchbox = $this->find_field(get_string('searchinsettings', 'admin'));
+            $searchbox = $this->find_field(get_string('query', 'admin'));
             $searchbox->setValue($label);
-            $submitsearch = $this->find('css', 'form.adminsearchform input[type=submit]');
+            $submitsearch = $this->find('css', 'form input[type=submit][name=search]');
             $submitsearch->press();
 
             $this->wait(self::TIMEOUT * 1000, self::PAGE_READY_JS);
@@ -78,21 +74,24 @@ class behat_admin extends behat_base {
 
             // Single element settings.
             try {
-                $fieldxpath = "//*[self::input | self::textarea | self::select][not(./@type = 'submit' or ./@type = 'image' or ./@type = 'hidden')]" .
-                    "[@id=//label[contains(normalize-space(.), $label)]/@for or " .
-                    "@id=//span[contains(normalize-space(.), $label)]/preceding-sibling::label[1]/@for]";
+                $fieldxpath = "//*[self::input | self::textarea | self::select]" .
+                        "[not(./@type = 'submit' or ./@type = 'image' or ./@type = 'hidden')]" .
+                        "[@id=//label[contains(normalize-space(.), $label)]/@for or " .
+                        "@id=//span[contains(normalize-space(.), $label)]/preceding-sibling::label[1]/@for]";
                 $fieldnode = $this->find('xpath', $fieldxpath, $exception);
 
-                $formfieldtypenode = $this->find('xpath', $fieldxpath . "/ancestor::div[@class='form-setting']" .
-                    "/child::div[contains(concat(' ', @class, ' '),  ' form-')]/child::*/parent::div");
+                $formfieldtypenode = $this->find('xpath', $fieldxpath .
+                        "/ancestor::div[contains(concat(' ', @class, ' '), ' form-setting ')]" .
+                        "/child::div[contains(concat(' ', @class, ' '),  ' form-')]/child::*/parent::div");
 
             } catch (ElementNotFoundException $e) {
 
                 // Multi element settings, interacting only the first one.
-                $fieldxpath = "//*[label[normalize-space(.)= $label]|span[normalize-space(.)= $label]]/" .
-                    "ancestor::div[contains(concat(' ', normalize-space(@class), ' '), ' form-item ')]" .
-                    "/descendant::div[@class='form-group']/descendant::*[self::input | self::textarea | self::select]" .
-                    "[not(./@type = 'submit' or ./@type = 'image' or ./@type = 'hidden')]";
+                $fieldxpath = "//*[label[contains(., $label)]|span[contains(., $label)]]" .
+                        "/ancestor::div[contains(concat(' ', normalize-space(@class), ' '), ' form-item ')]" .
+                        "/descendant::div[contains(concat(' ', @class, ' '), ' form-group ')]" .
+                        "/descendant::*[self::input | self::textarea | self::select]" .
+                        "[not(./@type = 'submit' or ./@type = 'image' or ./@type = 'hidden')]";
                 $fieldnode = $this->find('xpath', $fieldxpath);
 
                 // It is the same one that contains the type.
@@ -101,6 +100,7 @@ class behat_admin extends behat_base {
 
             // Getting the class which contains the field type.
             $classes = explode(' ', $formfieldtypenode->getAttribute('class'));
+            $type = false;
             foreach ($classes as $class) {
                 if (substr($class, 0, 5) == 'form-') {
                     $type = substr($class, 5);
index e9e2e3c..c66254a 100644 (file)
Binary files a/admin/tool/analytics/amd/build/model.min.js and b/admin/tool/analytics/amd/build/model.min.js differ
index 8913431..2f0b605 100644 (file)
@@ -36,6 +36,15 @@ define(['jquery', 'core/str', 'core/log', 'core/notification', 'core/modal_facto
                 component: 'tool_analytics'
             }
 
+        },
+        'delete': {
+            title: {
+                key: 'delete',
+                component: 'tool_analytics'
+            }, body: {
+                key: 'deletemodelconfirmation',
+                component: 'tool_analytics'
+            }
         }
     };
 
index 91fb876..2c0be86 100644 (file)
@@ -45,13 +45,25 @@ class edit_model extends \moodleform {
 
         $mform = $this->_form;
 
-        if ($this->_customdata['model']->is_trained()) {
+        if ($this->_customdata['trainedmodel']) {
             $message = get_string('edittrainedwarning', 'tool_analytics');
             $mform->addElement('html', $OUTPUT->notification($message, \core\output\notification::NOTIFY_WARNING));
         }
 
         $mform->addElement('advcheckbox', 'enabled', get_string('enabled', 'tool_analytics'));
 
+        if (!empty($this->_customdata['targets'])) {
+            $targets = array('' => '');
+            foreach ($this->_customdata['targets'] as $classname => $target) {
+                $optionname = \tool_analytics\output\helper::class_to_option($classname);
+                $targets[$optionname] = $target->get_name();
+            }
+
+            $mform->addElement('select', 'target', get_string('target', 'tool_analytics'), $targets);
+            $mform->addHelpButton('target', 'target', 'tool_analytics');
+            $mform->addRule('target', get_string('required'), 'required', null, 'client');
+        }
+
         $indicators = array();
         foreach ($this->_customdata['indicators'] as $classname => $indicator) {
             $optionname = \tool_analytics\output\helper::class_to_option($classname);
@@ -88,11 +100,13 @@ class edit_model extends \moodleform {
             $predictionprocessors);
         $mform->addHelpButton('predictionsprocessor', 'predictionsprocessor', 'analytics');
 
-        $mform->addElement('hidden', 'id', $this->_customdata['id']);
-        $mform->setType('id', PARAM_INT);
+        if (!empty($this->_customdata['id'])) {
+            $mform->addElement('hidden', 'id', $this->_customdata['id']);
+            $mform->setType('id', PARAM_INT);
 
-        $mform->addElement('hidden', 'action', 'edit');
-        $mform->setType('action', PARAM_ALPHANUMEXT);
+            $mform->addElement('hidden', 'action', 'edit');
+            $mform->setType('action', PARAM_ALPHANUMEXT);
+        }
 
         $this->add_action_buttons();
     }
diff --git a/admin/tool/analytics/classes/output/form/import_model.php b/admin/tool/analytics/classes/output/form/import_model.php
new file mode 100644 (file)
index 0000000..1b4c375
--- /dev/null
@@ -0,0 +1,56 @@
+<?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/>.
+
+/**
+ * Model upload form.
+ *
+ * @package   tool_analytics
+ * @copyright 2017 onwards Ankit Agarwal
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace tool_analytics\output\form;
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Model upload form.
+ *
+ * @package   tool_analytics
+ * @copyright 2017 onwards Ankit Agarwal
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class import_model extends \moodleform {
+
+    /**
+     * Form definition.
+     *
+     * @return null
+     */
+    public function definition () {
+        $mform = $this->_form;
+
+        $mform->addElement('header', 'settingsheader', get_string('importmodel', 'tool_analytics'));
+
+        $mform->addElement('filepicker', 'modelfile', get_string('file'), null, ['accepted_types' => '.zip']);
+        $mform->addRule('modelfile', null, 'required');
+
+        $mform->addElement('advcheckbox', 'ignoreversionmismatches', get_string('ignoreversionmismatches', 'tool_analytics'),
+            get_string('ignoreversionmismatchescheckbox', 'tool_analytics'));
+
+        $this->add_action_buttons(true, get_string('import'));
+    }
+}
index 8d121e2..6543022 100644 (file)
@@ -58,4 +58,38 @@ class helper {
         // Really unlikely but yeah, I'm a bad booyyy.
         return str_replace('2015102400ouuu', '\\', $option);
     }
+
+    /**
+     * Sets an analytics > analytics models > $title breadcrumb.
+     *
+     * @param string $title
+     * @param \moodle_url $url
+     * @param \context|null $context Defaults to context_system
+     * @return null
+     */
+    public static function set_navbar(string $title, \moodle_url $url, ?\context $context = null) {
+        global $PAGE;
+
+        if (!$context) {
+            $context = \context_system::instance();
+        }
+
+        $PAGE->set_context($context);
+        $PAGE->set_url($url);
+
+        if ($siteadmin = $PAGE->settingsnav->find('root', \navigation_node::TYPE_SITE_ADMIN)) {
+            $PAGE->navbar->add($siteadmin->get_content(), $siteadmin->action());
+        }
+        if ($analytics = $PAGE->settingsnav->find('analytics', \navigation_node::TYPE_SETTING)) {
+            $PAGE->navbar->add($analytics->get_content(), $analytics->action());
+        }
+        if ($analyticmodels = $PAGE->settingsnav->find('analyticmodels', \navigation_node::TYPE_SETTING)) {
+            $PAGE->navbar->add($analyticmodels->get_content(), $analyticmodels->action());
+        }
+        $PAGE->navbar->add($title, $url);
+
+        $PAGE->set_pagelayout('report');
+        $PAGE->set_title($title);
+        $PAGE->set_heading($title);
+    }
 }
index 83e2755..b3a1c43 100644 (file)
@@ -62,6 +62,8 @@ class models_list implements \renderable, \templatable {
         global $PAGE;
 
         $data = new \stdClass();
+        $data->importmodelurl = new \moodle_url('/admin/tool/analytics/importmodel.php');
+        $data->createmodelurl = new \moodle_url('/admin/tool/analytics/createmodel.php');
 
         $onlycli = get_config('analytics', 'onlycli');
         if ($onlycli === false) {
@@ -229,10 +231,19 @@ class models_list implements \renderable, \templatable {
 
             // Export training data.
             if (!$model->is_static() && $model->is_trained()) {
-                $urlparams['action'] = 'export';
+                $urlparams['action'] = 'exportdata';
                 $url = new \moodle_url('model.php', $urlparams);
                 $icon = new \action_menu_link_secondary($url, new \pix_icon('i/export',
-                    get_string('exporttrainingdata', 'tool_analytics')), get_string('export', 'tool_analytics'));
+                    get_string('exporttrainingdata', 'tool_analytics')), get_string('exporttrainingdata', 'tool_analytics'));
+                $actionsmenu->add($icon);
+            }
+
+            // Export model.
+            if (!$model->is_static() && $model->get_indicators() && !empty($modeldata->timesplitting)) {
+                $urlparams['action'] = 'exportmodel';
+                $url = new \moodle_url('model.php', $urlparams);
+                $icon = new \action_menu_link_secondary($url, new \pix_icon('i/backup',
+                    get_string('exportmodel', 'tool_analytics')), get_string('exportmodel', 'tool_analytics'));
                 $actionsmenu->add($icon);
             }
 
@@ -258,6 +269,15 @@ class models_list implements \renderable, \templatable {
                 $actionsmenu->add($icon);
             }
 
+            $actionid = 'delete-' . $model->get_id();
+            $PAGE->requires->js_call_amd('tool_analytics/model', 'confirmAction', [$actionid, 'delete']);
+            $urlparams['action'] = 'delete';
+            $url = new \moodle_url('model.php', $urlparams);
+            $icon = new \action_menu_link_secondary($url, new \pix_icon('t/delete',
+                get_string('delete', 'tool_analytics')), get_string('delete', 'tool_analytics'),
+                ['data-action-id' => $actionid]);
+            $actionsmenu->add($icon);
+
             $modeldata->actions = $actionsmenu->export_for_template($output);
 
             $data->models[] = $modeldata;
diff --git a/admin/tool/analytics/createmodel.php b/admin/tool/analytics/createmodel.php
new file mode 100644 (file)
index 0000000..bf1701c
--- /dev/null
@@ -0,0 +1,94 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Create model form.
+ *
+ * @package    tool_analytics
+ * @copyright  2019 David Monllao {@link http://www.davidmonllao.com}
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+require_once(__DIR__ . '/../../../config.php');
+
+require_login();
+\core_analytics\manager::check_can_manage_models();
+
+$returnurl = new \moodle_url('/admin/tool/analytics/index.php');
+$url = new \moodle_url('/admin/tool/analytics/createmodel.php');
+$title = get_string('createmodel', 'tool_analytics');
+
+\tool_analytics\output\helper::set_navbar($title, $url);
+
+// Static targets are not editable, we discard them.
+$targets = array_filter(\core_analytics\manager::get_all_targets(), function($target) {
+    return (!$target->based_on_assumptions());
+});
+
+$customdata = array(
+    'trainedmodel' => false,
+    'targets' => $targets,
+    'indicators' => \core_analytics\manager::get_all_indicators(),
+    'timesplittings' => \core_analytics\manager::get_enabled_time_splitting_methods(),
+    'predictionprocessors' => \core_analytics\manager::get_all_prediction_processors(),
+);
+$mform = new \tool_analytics\output\form\edit_model(null, $customdata);
+
+if ($mform->is_cancelled()) {
+    redirect($returnurl);
+
+} else if ($data = $mform->get_data()) {
+
+    // Converting option names to class names.
+    $targetclass = \tool_analytics\output\helper::option_to_class($data->target);
+    if (empty($targets[$targetclass])) {
+        throw new \moodle_exception('errorinvalidtarget', 'analytics', '', $targetclass);
+    }
+    $target = $targets[$targetclass];
+
+    $indicators = array();
+    foreach ($data->indicators as $indicator) {
+        $indicatorinstance = \core_analytics\manager::get_indicator(
+            \tool_analytics\output\helper::option_to_class($indicator)
+        );
+        $indicators[$indicatorinstance->get_id()] = $indicatorinstance;
+    }
+    $timesplitting = \tool_analytics\output\helper::option_to_class($data->timesplitting);
+    $predictionsprocessor = \tool_analytics\output\helper::option_to_class($data->predictionsprocessor);
+
+    // Insert the model into db.
+    $model = \core_analytics\model::create($target, []);
+
+    // Filter out indicators that can not be used by this target.
+    $invalidindicators = array_diff_key($indicators, $model->get_potential_indicators());
+    if ($invalidindicators) {
+        $indicators = array_diff_key($indicators, $invalidindicators);
+    }
+
+    // Update the model with the valid list of indicators.
+    $model->update($data->enabled, $indicators, $timesplitting, $predictionsprocessor);
+
+    $message = '';
+    $messagetype = \core\output\notification::NOTIFY_SUCCESS;
+    if (!empty($invalidindicators)) {
+        $message = get_string('invalidindicatorsremoved', 'tool_analytics');
+    }
+    redirect($returnurl, $message, 0, $messagetype);
+}
+
+echo $OUTPUT->header();
+$mform->display();
+echo $OUTPUT->footer();
diff --git a/admin/tool/analytics/importmodel.php b/admin/tool/analytics/importmodel.php
new file mode 100644 (file)
index 0000000..eedeaa0
--- /dev/null
@@ -0,0 +1,59 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Import models tool frontend.
+ *
+ * @package tool_analytics
+ * @copyright 2017 onwards Ankit Agarwal
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+require_once(__DIR__ . '/../../../config.php');
+
+require_login();
+\core_analytics\manager::check_can_manage_models();
+
+$returnurl = new \moodle_url('/admin/tool/analytics/index.php');
+$url = new \moodle_url('/admin/tool/analytics/importmodel.php');
+$title = get_string('importmodel', 'tool_analytics');
+
+\tool_analytics\output\helper::set_navbar($title, $url);
+
+$form = new \tool_analytics\output\form\import_model();
+if ($form->is_cancelled()) {
+    redirect($returnurl);
+} else if ($data = $form->get_data()) {
+
+    $modelconfig = new \core_analytics\model_config();
+
+    $zipfilepath = $form->save_temp_file('modelfile');
+
+    list ($modeldata, $unused) = $modelconfig->extract_import_contents($zipfilepath);
+
+    if ($error = $modelconfig->check_dependencies($modeldata, $data->ignoreversionmismatches)) {
+        // The file is not available until the form is validated so we need an alternative method to show errors.
+        redirect($url, $error, 0, \core\output\notification::NOTIFY_ERROR);
+    }
+    \core_analytics\model::import_model($zipfilepath);
+
+    redirect($returnurl, get_string('importedsuccessfully', 'tool_analytics'), 0,
+        \core\output\notification::NOTIFY_SUCCESS);
+}
+
+echo $OUTPUT->header();
+$form->display();
+echo $OUTPUT->footer();
\ No newline at end of file
index 1c3bba2..580d7b8 100644 (file)
@@ -29,11 +29,15 @@ $string['analyticmodels'] = 'Analytics models';
 $string['bettercli'] = 'Evaluating models and generating predictions may involve heavy processing. It is recommended to run these actions from the command line.';
 $string['cantguessstartdate'] = 'Can\'t guess the start date';
 $string['cantguessenddate'] = 'Can\'t guess the end date';
+$string['classdoesnotexist'] = 'Class {$a} does not exist';
 $string['clearpredictions'] = 'Clear predictions';
 $string['clearmodelpredictions'] = 'Are you sure you want to clear all "{$a}" predictions?';
 $string['clienablemodel'] = 'You can enable the model by selecting a time-splitting method by its ID. Note that you can also enable it later using the web interface (\'none\' to exit).';
 $string['clievaluationandpredictions'] = 'A scheduled task iterates through enabled models and gets predictions. Models evaluation via the web interface is disabled. You can allow these processes to be executed manually via the web interface by disabling the <a href="{$a}">\'onlycli\'</a> analytics setting.';
 $string['clievaluationandpredictionsnoadmin'] = 'A scheduled task iterates through enabled models and gets predictions. Models evaluation via the web interface is disabled. It may be enabled by a site administrator.';
+$string['createmodel'] = 'Create model';
+$string['delete'] = 'Delete';
+$string['deletemodelconfirmation'] = 'Are you sure you want to delete "{$a}"? These changes can not be reverted.';
 $string['disabled'] = 'Disabled';
 $string['editmodel'] = 'Edit "{$a}" model';
 $string['edittrainedwarning'] = 'This model has already been trained. Note that changing its indicators or its time-splitting method will delete its previous predictions and start generating new predictions.';
@@ -50,7 +54,7 @@ $string['errortrainingdataexport'] = 'The model training data could not be expor
 $string['evaluate'] = 'Evaluate';
 $string['evaluatemodel'] = 'Evaluate model';
 $string['evaluationinbatches'] = 'The site contents are calculated and stored in batches. The evaluation process may be stopped at any time. The next time it is run, it will continue from the point when it was stopped.';
-$string['export'] = 'Export';
+$string['exportmodel'] = 'Export configuration';
 $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';
@@ -58,15 +62,21 @@ $string['extrainfo'] = 'Info';
 $string['generalerror'] = 'Evaluation error. Status code {$a}';
 $string['getpredictions'] = 'Get predictions';
 $string['goodmodel'] = 'This is a good model for using to obtain predictions. Enable it to start obtaining predictions.';
+$string['importmodel'] = 'Import model';
 $string['indicators'] = 'Indicators';
 $string['info'] = 'Info';
+$string['ignoreversionmismatches'] = 'Ignore version mismatches';
+$string['ignoreversionmismatchescheckbox'] = 'Ignore the differences between this site version and the original site version.';
+$string['importedsuccessfully'] = 'The model has been successfully imported.';
 $string['insights'] = 'Insights';
 $string['invalidanalysables'] = 'Invalid site elements';
 $string['invalidanalysablesinfo'] = 'This pages lists this site analysable elements that can not be used by this prediction model. The listed elements can not be used neither to train the prediction model nor the prediction model can get predictions for them.';
 $string['invalidanalysablestable'] = 'Invalid site analysable elements table';
+$string['invalidindicatorsremoved'] = 'A new model has been added. Indicators that do not work with the selected target have been automatically removed.';
 $string['invalidprediction'] = 'Invalid to get predictions';
 $string['invalidtraining'] = 'Invalid to train the model';
 $string['loginfo'] = 'Log extra info';
+$string['missingmoodleversion'] = 'Imported file does not define a moodle version number';
 $string['modelid'] = 'Model ID';
 $string['modelinvalidanalysables'] = 'Invalid analysable elements for "{$a}" model';
 $string['modelresults'] = '{$a} results';
@@ -86,12 +96,14 @@ $string['previouspage'] = 'Previous page';
 $string['samestartdate'] = 'Current start date is good';
 $string['sameenddate'] = 'Current end date is good';
 $string['target'] = 'Target';
+$string['target_help'] = 'The target is what the model will predict.';
 $string['timesplittingnotdefined'] = 'Time splitting is not defined.';
 $string['timesplittingnotdefined_help'] = 'You need to select a time-splitting method before enabling the model.';
 $string['trainandpredictmodel'] = 'Training model and calculating predictions';
 $string['trainingprocessfinished'] = 'Training process finished';
 $string['trainingresults'] = 'Training results';
 $string['trainmodels'] = 'Train models';
+$string['versionnotsame'] = 'Imported file was from a different moodle version ({$a->importedversion}) than the current one ({$a->version})';
 $string['viewlog'] = 'Log';
 $string['weeksenddateautomaticallyset'] = 'End date automatically set based on start date and the number of sections';
 $string['weeksenddatedefault'] = 'End date automatically calculated from the course start date.';
index b70fc24..23d8454 100644 (file)
  */
 
 require_once(__DIR__ . '/../../../config.php');
+require_once($CFG->libdir . '/filelib.php');
 
 $id = required_param('id', PARAM_INT);
 $action = required_param('action', PARAM_ALPHANUMEXT);
 
-$context = context_system::instance();
-
 require_login();
 
 $model = new \core_analytics\model($id);
 \core_analytics\manager::check_can_manage_models();
 
+$returnurl = new \moodle_url('/admin/tool/analytics/index.php');
 $params = array('id' => $id, 'action' => $action);
 $url = new \moodle_url('/admin/tool/analytics/model.php', $params);
 
@@ -57,8 +57,14 @@ switch ($action) {
     case 'disable':
         $title = get_string('disable');
         break;
-    case 'export':
-        $title = get_string('export', 'tool_analytics');
+    case 'delete':
+        $title = get_string('delete');
+        break;
+    case 'exportdata':
+        $title = get_string('exporttrainingdata', 'tool_analytics');
+        break;
+    case 'exportmodel':
+        $title = get_string('exportmodel', 'tool_analytics');
         break;
     case 'clear':
         $title = get_string('clearpredictions', 'tool_analytics');
@@ -70,11 +76,7 @@ switch ($action) {
         throw new moodle_exception('errorunknownaction', 'analytics');
 }
 
-$PAGE->set_context($context);
-$PAGE->set_url($url);
-$PAGE->set_pagelayout('report');
-$PAGE->set_title($title);
-$PAGE->set_heading($title);
+\tool_analytics\output\helper::set_navbar($title, $url);
 
 $onlycli = get_config('analytics', 'onlycli');
 if ($onlycli === false) {
@@ -88,14 +90,21 @@ switch ($action) {
         confirm_sesskey();
 
         $model->enable();
-        redirect(new \moodle_url('/admin/tool/analytics/index.php'));
+        redirect($returnurl);
         break;
 
     case 'disable':
         confirm_sesskey();
 
         $model->update(0, false, false);
-        redirect(new \moodle_url('/admin/tool/analytics/index.php'));
+        redirect($returnurl);
+        break;
+
+    case 'delete':
+        confirm_sesskey();
+
+        $model->delete();
+        redirect($returnurl);
         break;
 
     case 'edit':
@@ -108,7 +117,7 @@ switch ($action) {
 
         $customdata = array(
             'id' => $model->get_id(),
-            'model' => $model,
+            'trainedmodel' => $model->is_trained(),
             'indicators' => $model->get_potential_indicators(),
             'timesplittings' => \core_analytics\manager::get_enabled_time_splitting_methods(),
             'predictionprocessors' => \core_analytics\manager::get_all_prediction_processors()
@@ -116,7 +125,7 @@ switch ($action) {
         $mform = new \tool_analytics\output\form\edit_model(null, $customdata);
 
         if ($mform->is_cancelled()) {
-            redirect(new \moodle_url('/admin/tool/analytics/index.php'));
+            redirect($returnurl);
 
         } else if ($data = $mform->get_data()) {
 
@@ -129,7 +138,7 @@ switch ($action) {
             $timesplitting = \tool_analytics\output\helper::option_to_class($data->timesplitting);
             $predictionsprocessor = \tool_analytics\output\helper::option_to_class($data->predictionsprocessor);
             $model->update($data->enabled, $indicators, $timesplitting, $predictionsprocessor);
-            redirect(new \moodle_url('/admin/tool/analytics/index.php'));
+            redirect($returnurl);
         }
 
         echo $OUTPUT->header();
@@ -203,7 +212,7 @@ switch ($action) {
         echo $renderer->render_table($modellogstable);
         break;
 
-    case 'export':
+    case 'exportdata':
 
         if ($model->is_static() || !$model->is_trained()) {
             throw new moodle_exception('errornoexport', 'tool_analytics');
@@ -211,7 +220,7 @@ switch ($action) {
 
         $file = $model->get_training_data();
         if (!$file) {
-            redirect(new \moodle_url('/admin/tool/analytics/index.php'), get_string('errortrainingdataexport', 'tool_analytics'),
+            redirect($returnurl, get_string('errortrainingdataexport', 'tool_analytics'),
                 null, \core\output\notification::NOTIFY_ERROR);
         }
 
@@ -219,11 +228,17 @@ switch ($action) {
         send_file($file, $filename, null, 0, false, true);
         break;
 
+    case 'exportmodel':
+        $zipfilename = 'model-' . $model->get_unique_id() . '-' . microtime(false) . '.zip';
+        $zipfilepath = $model->export_model($zipfilename);
+        send_temp_file($zipfilepath, $zipfilename);
+        break;
+
     case 'clear':
         confirm_sesskey();
 
         $model->clear();
-        redirect(new \moodle_url('/admin/tool/analytics/index.php'));
+        redirect($returnurl);
         break;
 
     case 'invalidanalysables':
index efdc4ca..bd11e0b 100644 (file)
 {{/infos}}
 
 <div class="box">
+    <div class="top-nav d-flex">
+        <a href="{{createmodelurl}}" class="btn btn-default mr-2">{{#str}}createmodel, tool_analytics{{/str}}</a>
+        <a href="{{importmodelurl}}" class="btn btn-default">{{#str}}importmodel, tool_analytics{{/str}}</a>
+    </div>
     <table class="generaltable fullwidth">
         <caption>{{#str}}analyticmodels, tool_analytics{{/str}}</caption>
         <thead>
index 01c0396..c8819d6 100644 (file)
@@ -24,7 +24,7 @@
 
 // NOTE: no MOODLE_INTERNAL test here, this file may be required by behat before including /config.php.
 
-require_once(__DIR__ . '/../../../../../lib/behat/behat_base.php');
+require_once(__DIR__ . '/../../../../../../lib/behat/behat_base.php');
 
 /**
  * Test context 1
index 0dbdbeb..9b423ae 100644 (file)
@@ -24,7 +24,7 @@
 
 // NOTE: no MOODLE_INTERNAL test here, this file may be required by behat before including /config.php.
 
-require_once(__DIR__ . '/../../../../../lib/behat/behat_base.php');
+require_once(__DIR__ . '/../../../../../../lib/behat/behat_base.php');
 
 /**
  * Test context 2
index ff164cd..f0d851c 100644 (file)
@@ -24,7 +24,7 @@
 
 // NOTE: no MOODLE_INTERNAL test here, this file may be required by behat before including /config.php.
 
-require_once(__DIR__ . '/../../../../../lib/behat/behat_base.php');
+require_once(__DIR__ . '/../../../../../../../lib/behat/behat_base.php');
 
 /**
  * Default Theme test context 1
index 710da0a..5db9807 100644 (file)
@@ -24,7 +24,7 @@
 
 // NOTE: no MOODLE_INTERNAL test here, this file may be required by behat before including /config.php.
 
-require_once(__DIR__ . '/../behat_test_context_1.php');
+require_once(__DIR__ . '/../../core/behat_test_context_1.php');
 
 /**
  * Theme test context 1
@@ -33,6 +33,6 @@ require_once(__DIR__ . '/../behat_test_context_1.php');
  * @copyright  2016 Rajesh Taneja <rajesh@moodle.com>
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
-class behat_theme_nofeatures_behat_test_context_1 extends behat_test_context_1 {
+class behat_theme_nofeatures_behat_test_context_2 extends behat_test_context_2 {
 
 }
\ No newline at end of file
index 87fe40c..2d8d121 100644 (file)
@@ -24,7 +24,7 @@
 
 // NOTE: no MOODLE_INTERNAL test here, this file may be required by behat before including /config.php.
 
-require_once(__DIR__ . '/../../../../../lib/behat/behat_base.php');
+require_once(__DIR__ . '/../../../../../../../lib/behat/behat_base.php');
 
 /**
  * Theme test context 2
@@ -33,6 +33,6 @@ require_once(__DIR__ . '/../../../../../lib/behat/behat_base.php');
  * @copyright  2016 Rajesh Taneja <rajesh@moodle.com>
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
-class behat_theme_nofeatures_test_context_2 extends behat_base {
+class behat_theme_nofeatures_test_context_1 extends behat_base {
 
 }
\ No newline at end of file
index 3941071..1111532 100644 (file)
@@ -24,7 +24,7 @@
 
 // NOTE: no MOODLE_INTERNAL test here, this file may be required by behat before including /config.php.
 
-require_once(__DIR__ . '/../behat_test_context_1.php');
+require_once(__DIR__ . '/../../core/behat_test_context_1.php');
 
 /**
  * Theme test context 1
index 23eedff..2265320 100644 (file)
@@ -24,7 +24,7 @@
 
 // NOTE: no MOODLE_INTERNAL test here, this file may be required by behat before including /config.php.
 
-require_once(__DIR__ . '/../../../../../lib/behat/behat_base.php');
+require_once(__DIR__ . '/../../../../../../../lib/behat/behat_base.php');
 
 /**
  * Theme test context 2
index 2d10776..98ccd03 100644 (file)
@@ -108,7 +108,6 @@ class tool_behat_manager_util_testcase extends advanced_testcase {
             array('nofeatures', __DIR__.'/fixtures/theme/nofeatures'),
             array('defaulttheme', __DIR__.'/fixtures/theme/defaulttheme'),
         );
-
         // List of themes is const for test.
         if ($notheme) {
             $themelist = array('defaulttheme');
@@ -116,6 +115,13 @@ class tool_behat_manager_util_testcase extends advanced_testcase {
             $themelist = array('withfeatures', 'nofeatures', 'defaulttheme');
         }
 
+        $thememap = [];
+        foreach ($themelist as $themename) {
+            $mock = $this->getMockBuilder('theme_config');
+            $mock->disableOriginalConstructor();
+            $thememap[] = [$themename, $mock->getMock()];
+        }
+
         $behatconfigutil->expects($this->any())
             ->method('get_list_of_themes')
             ->will($this->returnValue($themelist));
@@ -125,6 +131,11 @@ class tool_behat_manager_util_testcase extends advanced_testcase {
             ->method('get_theme_test_directory')
             ->will($this->returnValueMap($map));
 
+        // Theme directory for testing.
+        $behatconfigutil->expects($this->any())
+                ->method('get_theme_config')
+                ->will($this->returnValueMap($thememap));
+
         $behatconfigutil->expects($this->any())
             ->method('get_default_theme')
             ->will($this->returnValue('defaulttheme'));
@@ -138,7 +149,7 @@ class tool_behat_manager_util_testcase extends advanced_testcase {
     public function test_get_config_file_contents_with_single_run() {
 
         $mockbuilder = $this->getMockBuilder('behat_config_util');
-        $mockbuilder->setMethods(array('get_theme_test_directory', 'get_list_of_themes', 'get_default_theme'));
+        $mockbuilder->setMethods(array('get_theme_test_directory', 'get_list_of_themes', 'get_default_theme', 'get_theme_config'));
 
         $behatconfigutil = $mockbuilder->getMock();
 
@@ -177,7 +188,7 @@ class tool_behat_manager_util_testcase extends advanced_testcase {
     public function test_get_config_file_contents_with_single_run_no_theme() {
 
         $mockbuilder = $this->getMockBuilder('behat_config_util');
-        $mockbuilder->setMethods(array('get_theme_test_directory', 'get_list_of_themes', 'get_default_theme'));
+        $mockbuilder->setMethods(array('get_theme_test_directory', 'get_list_of_themes', 'get_default_theme', 'get_theme_config'));
 
         $behatconfigutil = $mockbuilder->getMock();
 
@@ -231,7 +242,7 @@ class tool_behat_manager_util_testcase extends advanced_testcase {
     public function test_get_config_file_contents_with_parallel_run() {
 
         $mockbuilder = $this->getMockBuilder('behat_config_util');
-        $mockbuilder->setMethods(array('get_theme_test_directory', 'get_list_of_themes', 'get_default_theme'));
+        $mockbuilder->setMethods(array('get_theme_test_directory', 'get_list_of_themes', 'get_default_theme', 'get_theme_config'));
 
         $behatconfigutil = $mockbuilder->getMock();
 
@@ -334,7 +345,7 @@ class tool_behat_manager_util_testcase extends advanced_testcase {
     public function test_get_config_file_contents_with_parallel_run_optimize_tags() {
 
         $mockbuilder = $this->getMockBuilder('behat_config_util');
-        $mockbuilder->setMethods(array('get_theme_test_directory', 'get_list_of_themes', 'get_default_theme'));
+        $mockbuilder->setMethods(array('get_theme_test_directory', 'get_list_of_themes', 'get_default_theme', 'get_theme_config'));
 
         $behatconfigutil = $mockbuilder->getMock();
 
@@ -479,7 +490,7 @@ class tool_behat_manager_util_testcase extends advanced_testcase {
 
         $mockbuilder = $this->getMockBuilder('behat_config_util');
         $mockbuilder->setMethods(array('get_theme_test_directory', 'get_list_of_themes', 'get_blacklisted_tests_for_theme',
-            'get_default_theme'));
+            'get_default_theme', 'get_theme_config'));
 
         $behatconfigutil = $mockbuilder->getMock();
 
@@ -543,7 +554,7 @@ class tool_behat_manager_util_testcase extends advanced_testcase {
 
         $mockbuilder = $this->getMockBuilder('behat_config_util');
         $mockbuilder->setMethods(array('get_theme_test_directory', 'get_list_of_themes', 'get_blacklisted_tests_for_theme',
-            'get_default_theme'));
+            'get_default_theme', 'get_theme_config'));
 
         $behatconfigutil = $mockbuilder->getMock();
 
@@ -621,7 +632,7 @@ class tool_behat_manager_util_testcase extends advanced_testcase {
     public function test_core_features_to_include_in_specified_theme() {
 
         $mockbuilder = $this->getMockBuilder('behat_config_util');
-        $mockbuilder->setMethods(array('get_theme_test_directory', 'get_list_of_themes', 'get_default_theme'));
+        $mockbuilder->setMethods(array('get_theme_test_directory', 'get_list_of_themes', 'get_default_theme', 'get_theme_config'));
 
         $behatconfigutil = $mockbuilder->getMock();
 
index 525aa94..36b1184 100644 (file)
@@ -1,5 +1,8 @@
 This files describes API changes in the tool_behat code.
 
+=== 3.7 ===
+* Behat will now look for behat step definitions in the current
+  theme and any parents the theme may have.
 === 2.7 ===
 * Constants behat_base::cap_allow, behat_base::cap_prevent and
   behat_base::cap_prohibit have been removed in favour of the
index 118f761..b7029db 100644 (file)
@@ -20,7 +20,6 @@
     Moodle progress bar template for tool_lp.
 
     The purpose of this template is to render a progress bar with a brief description.
-    Inherits core/columns-1to2.
 
     Classes required for JS:
     * none
     }
 
 }}
-<div class="row-fluid rtl-compatible">
-    <div class="span4">
-        <div class="progresstext">
-            {{$progresstext}}{{progresstextvalue}}{{/progresstext}}
-        </div>
-    </div>
-    <div class="span8">
-        <div class="progress">
-            <div class="bar" style="width: {{$percentage}}{{percentagevalue}}{{/percentage}}%;" role="progressbar" aria-valuenow="{{$percentage}}{{percentagevalue}}{{/percentage}}" aria-valuemin="0" aria-valuemax="100">
-                {{$percentlabel}}{{percentlabelvalue}}{{/percentlabel}}
-            </div>
-        </div>
-    </div>
+<div id="progress-{{uniqid}}">
+    {{$progresstext}}{{progresstextvalue}}{{/progresstext}}
 </div>
+<progress class="progress" aria-describedby="progress-{{uniqid}}"
+    value="{{$percentage}}{{percentagevalue}}{{/percentage}}" max="100"></progress>
index bfa0e8d..aaa36d9 100644 (file)
@@ -7,7 +7,7 @@ Feature: Clear scheduled task fail delay
   Background:
     Given the scheduled task "\core\task\send_new_user_passwords_task" has a fail delay of "60" seconds
     And I log in as "admin"
-    And I navigate to "Server > Scheduled tasks" in site administration
+    And I navigate to "Server > Tasks > Scheduled tasks" in site administration
 
   Scenario: Clear fail delay
     When I click on "Clear" "text" in the "Send new user passwords" "table_row"
index 1f0a9df..4da19e5 100644 (file)
@@ -6,7 +6,7 @@ Feature: Manage scheduled tasks
 
   Background:
     Given I log in as "admin"
-    And I navigate to "Server > Scheduled tasks" in site administration
+    And I navigate to "Server > Tasks > Scheduled tasks" in site administration
 
   Scenario: Disable scheduled task
     When I click on "Edit task schedule: Log table cleanup" "link" in the "Log table cleanup" "table_row"
index 886c250..3771835 100644 (file)
Binary files a/admin/tool/usertours/amd/build/managesteps.min.js and b/admin/tool/usertours/amd/build/managesteps.min.js differ
index 9c9e2e6..75c7d1b 100644 (file)
Binary files a/admin/tool/usertours/amd/build/managetours.min.js and b/admin/tool/usertours/amd/build/managetours.min.js differ
index ba52019..420b89e 100644 (file)
@@ -18,6 +18,7 @@ function($, str, notification) {
          */
         removeStep: function(e) {
             e.preventDefault();
+            var targetUrl = $(e.currentTarget).attr('href');
             str.get_strings([
                 {
                     key:        'confirmstepremovaltitle',
@@ -35,11 +36,15 @@ function($, str, notification) {
                     key:        'no',
                     component:  'moodle'
                 }
-            ]).done(function(s) {
-                notification.confirm(s[0], s[1], s[2], s[3], $.proxy(function() {
-                    window.location = $(this).attr('href');
-                }, e.currentTarget));
-            });
+            ])
+            .then(function(s) {
+                notification.confirm(s[0], s[1], s[2], s[3], function() {
+                    window.location = targetUrl;
+                });
+
+                return;
+            })
+            .catch();
         },
 
         /**
index f5c601a..f6d3237 100644 (file)
@@ -18,7 +18,7 @@ function($, ajax, str, notification) {
          */
         removeTour: function(e) {
             e.preventDefault();
-
+            var targetUrl = $(e.currentTarget).attr('href');
             str.get_strings([
                 {
                     key:        'confirmtourremovaltitle',
@@ -36,11 +36,15 @@ function($, ajax, str, notification) {
                     key:        'no',
                     component:  'moodle'
                 }
-            ]).done(function(s) {
-                notification.confirm(s[0], s[1], s[2], s[3], $.proxy(function() {
-                    window.location = $(this).attr('href');
-                }, e.currentTarget));
-            });
+            ])
+            .then(function(s) {
+                notification.confirm(s[0], s[1], s[2], s[3], function() {
+                    window.location = targetUrl;
+                });
+
+                return;
+            })
+            .catch();
         },
 
         /**
index 14a96e0..1e4cdbb 100644 (file)
@@ -497,6 +497,12 @@ class helper {
             return;
         }
 
+        if (in_array($PAGE->pagelayout, ['maintenance', 'print', 'redirect'])) {
+            // Do not try to show user tours inside iframe, in maintenance mode,
+            // when printing, or during redirects.
+            return;
+        }
+
         if (self::$bootstrapped) {
             return;
         }
index a4fb976..192fec4 100644 (file)
     }
 
 }}
-<div class="modal" data-role="flexitour-step">
-    <div data-role="arrow"></div>
+<div class="modal-dialog" role="document" data-role="flexitour-step">
+  <div class="modal-content">
+    <div class="tooltip-arrow" data-role="arrow"></div>
     <div class="modal-header">
-      <button type="button" class="close" data-dismiss="modal" aria-hidden="true" data-role="end">&times;</button>
-      <h3 data-placeholder="title"></h3>
+      <h5 class="modal-title" data-placeholder="title"></h5>
+      <button type="button" class="close" data-dismiss="modal" aria-label="Close" data-role="end">
+        <span aria-hidden="true">&times;</span>
+      </button>
+
+    </div>
+    <div class="modal-body" data-placeholder="body">
     </div>
-    <div class="modal-body" data-placeholder="body"></div>
     <div class="modal-footer">
-        <div class="btn-group">
-            <button href="#" class="btn" data-role="previous">{{# str }} previous, moodle {{/ str }}</button>
-            <button href="#" class="btn btn-primary" data-role="next">{{# str }} next, moodle {{/ str }}</button>
-        </div>
-        <button class="btn" data-role="end"> {{# str }} endtour, tool_usertours {{/ str }} </button>
+      <button type="button" class="btn btn-secondary" data-role="previous">{{# str }} previous, moodle {{/ str }}</button>
+      <button type="button" class="btn btn-primary" data-role="next">{{# str }} next, moodle {{/ str }}</button>
+      <button class="btn btn-secondary" data-role="end"> {{# str }} endtour, tool_usertours {{/ str }} </button>
     </div>
+  </div>
 </div>
index af95120..ceea63a 100644 (file)
@@ -82,6 +82,7 @@ abstract class calculable {
      * @return string
      */
     public function get_id() {
+        // Using get_class as get_component_classes_in_namespace returns double escaped fully qualified class names.
         return '\\' . get_class($this);
     }
 
index ddc70e0..1c10cde 100644 (file)
@@ -45,6 +45,11 @@ class manager {
      */
     protected static $predictionprocessors = null;
 
+    /**
+     * @var \core_analytics\local\target\base[]
+     */
+    protected static $alltargets = null;
+
     /**
      * @var \core_analytics\local\indicator\base[]
      */
@@ -281,6 +286,28 @@ class manager {
         return new $fullclassname();
     }
 
+    /**
+     * Return all targets in the system.
+     *
+     * @return \core_analytics\local\target\base[]
+     */
+    public static function get_all_targets() : array {
+        if (self::$alltargets !== null) {
+            return self::$alltargets;
+        }
+
+        $classes = self::get_analytics_classes('target');
+
+        self::$alltargets = [];
+        foreach ($classes as $fullclassname => $classpath) {
+            $instance = self::get_target($fullclassname);
+            if ($instance) {
+                self::$alltargets[$instance->get_id()] = $instance;
+            }
+        }
+
+        return self::$alltargets;
+    }
     /**
      * Return all system indicators.
      *
@@ -297,7 +324,6 @@ class manager {
         foreach ($classes as $fullclassname => $classpath) {
             $instance = self::get_indicator($fullclassname);
             if ($instance) {
-                // Using get_class as get_component_classes_in_namespace returns double escaped fully qualified class names.
                 self::$allindicators[$instance->get_id()] = $instance;
             }
         }
index fa6e91e..3b6c6e4 100644 (file)
@@ -338,11 +338,12 @@ class model {
      *
      * @param \core_analytics\local\target\base $target
      * @param \core_analytics\local\indicator\base[] $indicators
-     * @param string $timesplittingid The time splitting method id (its fully qualified class name)
+     * @param string|false $timesplittingid The time splitting method id (its fully qualified class name)
+     * @param string|null $processor The machine learning backend this model will use.
      * @return \core_analytics\model
      */
     public static function create(\core_analytics\local\target\base $target, array $indicators,
-                                  $timesplittingid = false, $processor = false) {
+                                  $timesplittingid = false, $processor = null) {
         global $USER, $DB;
 
         \core_analytics\manager::check_can_manage_models();
@@ -360,8 +361,8 @@ class model {
         $modelobj->usermodified = $USER->id;
 
         if ($processor &&
-                !self::is_valid($processor, '\core_analytics\classifier') &&
-                !self::is_valid($processor, '\core_analytics\regressor')) {
+                !manager::is_valid($processor, '\core_analytics\classifier') &&
+                !manager::is_valid($processor, '\core_analytics\regressor')) {
             throw new \coding_exception('The provided predictions processor \\' . $processor . '\processor is not valid');
         } else {
             $modelobj->predictionsprocessor = $processor;
@@ -462,6 +463,7 @@ class model {
 
             // It needs to be reset as the version changes.
             $this->uniqueid = null;
+            $this->indicators = null;
 
             // We update the version of the model so different time splittings are not mixed up.
             $this->model->version = $now;
@@ -1308,7 +1310,7 @@ class model {
      * @param bool $onlymodelid Preference over $subdirs
      * @return string
      */
-    protected function get_output_dir($subdirs = array(), $onlymodelid = false) {
+    public function get_output_dir($subdirs = array(), $onlymodelid = false) {
         global $CFG;
 
         $subdirstr = '';
@@ -1357,7 +1359,7 @@ class model {
     }
 
     /**
-     * Exports the model data.
+     * Exports the model data for displaying it in a template.
      *
      * @return \stdClass
      */
@@ -1379,6 +1381,58 @@ class model {
         return $data;
     }
 
+    /**
+     * Exports the model data to a zip file.
+     *
+     * @param string $zipfilename
+     * @return string Zip file path
+     */
+    public function export_model(string $zipfilename) : string {
+
+        \core_analytics\manager::check_can_manage_models();
+
+        $modelconfig = new model_config($this);
+        return $modelconfig->export($zipfilename);
+    }
+
+    /**
+     * Imports the provided model.
+     *
+     * Note that this method assumes that model_config::check_dependencies has already been called.
+     *
+     * @throws \moodle_exception
+     * @param  string $zipfilepath Zip file path
+     * @return \core_analytics\model
+     */
+    public static function import_model(string $zipfilepath) : \core_analytics\model {
+
+        \core_analytics\manager::check_can_manage_models();
+
+        $modelconfig = new \core_analytics\model_config();
+        return $modelconfig->import($zipfilepath);
+    }
+
+    /**
+     * Can this model be exported?
+     *
+     * @return bool
+     */
+    public function can_export_configuration() : bool {
+
+        if (empty($this->model->timesplitting)) {
+            return false;
+        }
+        if (!$this->get_indicators()) {
+            return false;
+        }
+
+        if ($this->is_static()) {
+            return false;
+        }
+
+        return true;
+    }
+
     /**
      * Returns the model logs data.
      *
diff --git a/analytics/classes/model_config.php b/analytics/classes/model_config.php
new file mode 100644 (file)
index 0000000..2cb32cc
--- /dev/null
@@ -0,0 +1,326 @@
+<?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/>.
+
+/**
+ * Model configuration manager.
+ *
+ * @package   core_analytics
+ * @copyright 2019 David Monllao {@link http://www.davidmonllao.com}
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core_analytics;
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Model configuration manager.
+ *
+ * @package   core_analytics
+ * @copyright 2019 David Monllao {@link http://www.davidmonllao.com}
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class model_config {
+
+    /**
+     * @var \core_analytics\model
+     */
+    private $model = null;
+
+    /**
+     * The name of the file where config is held.
+     */
+    const CONFIG_FILE_NAME = 'model-config.json';
+
+    /**
+     * Constructor.
+     *
+     * @param \core_analytics\model|null $model
+     */
+    public function __construct(?model $model = null) {
+        $this->model = $model;
+    }
+
+    /**
+     * Exports a model to a zip using the provided file name.
+     *
+     * @param string $zipfilename
+     * @return string
+     */
+    public function export(string $zipfilename) : string {
+
+        if (!$this->model) {
+            throw new \coding_exception('No model object provided.');
+        }
+
+        if (!$this->model->can_export_configuration()) {
+            throw new \moodle_exception('errornoexportconfigrequirements', 'analytics');
+        }
+
+        $zip = new \zip_packer();
+        $zipfiles = [];
+
+        // Model config in JSON.
+        $modeldata = $this->export_model_data();
+
+        $exporttmpdir = make_request_directory('analyticsexport');
+        $jsonfilepath = $exporttmpdir . DIRECTORY_SEPARATOR . 'model-config.json';
+        if (!file_put_contents($jsonfilepath, json_encode($modeldata))) {
+            print_error('errornoexportconfig', 'analytics');
+        }
+        $zipfiles[self::CONFIG_FILE_NAME] = $jsonfilepath;
+
+        // ML backend.
+        if ($this->model->is_trained()) {
+            $processor = $this->model->get_predictions_processor(true);
+            $outputdir = $this->model->get_output_dir(array('execution'));
+            $mlbackenddir = $processor->export($this->model->get_unique_id(), $outputdir);
+            $mlbackendfiles = get_directory_list($mlbackenddir);
+            foreach ($mlbackendfiles as $mlbackendfile) {
+                $fullpath = $mlbackenddir . DIRECTORY_SEPARATOR . $mlbackendfile;
+                // Place the ML backend files inside a mlbackend/ dir.
+                $zipfiles['mlbackend/' . $mlbackendfile] = $fullpath;
+            }
+        }
+
+        $zipfilepath = $exporttmpdir . DIRECTORY_SEPARATOR . $zipfilename;
+        $zip->archive_to_pathname($zipfiles, $zipfilepath);
+
+        return $zipfilepath;
+    }
+
+    /**
+     * Imports the provided model configuration into a new model.
+     *
+     * Note that this method assumes that self::check_dependencies has already been called.
+     *
+     * @param  string $zipfilepath Path to the zip file to import
+     * @return \core_analytics\model
+     */
+    public function import(string $zipfilepath) : \core_analytics\model {
+
+        list($modeldata, $mlbackenddir) = $this->extract_import_contents($zipfilepath);
+
+        $target = \core_analytics\manager::get_target($modeldata->target);
+        $indicators = [];
+        foreach ($modeldata->indicators as $indicatorclass) {
+            $indicator = \core_analytics\manager::get_indicator($indicatorclass);
+            $indicators[$indicator->get_id()] = $indicator;
+        }
+        $model = \core_analytics\model::create($target, $indicators, $modeldata->timesplitting, $modeldata->processor);
+
+        // Import them disabled.
+        $model->update(false, false, false, false);
+
+        if ($mlbackenddir) {
+            $modeldir = $model->get_output_dir(['execution']);
+            if (!$model->get_predictions_processor(true)->import($model->get_unique_id(), $modeldir, $mlbackenddir)) {
+                throw new \moodle_exception('errorimport', 'analytics');
+            }
+            $model->mark_as_trained();
+        }
+
+        return $model;
+    }
+
+    /**
+     * Check that the provided model configuration can be deployed in this site.
+     *
+     * @param  \stdClass $modeldata
+     * @param  bool $ignoreversionmismatches
+     * @return string|null Error string or null if all good.
+     */
+    public function check_dependencies(\stdClass $modeldata, bool $ignoreversionmismatches) : ?string {
+
+        $siteversions = \core_component::get_all_versions();
+
+        // Possible issues.
+        $missingcomponents = [];
+        $versionmismatches = [];
+        $missingclasses = [];
+
+        // We first check that this site has the required dependencies and the required versions.
+        foreach ($modeldata->dependencies as $component => $importversion) {
+
+            if (empty($siteversions[$component])) {
+
+                if ($component === 'core') {
+                    $component = 'Moodle';
+                }
+                $missingcomponents[$component] = $component . ' (' . $importversion . ')';
+                continue;
+            }
+
+            if ($siteversions[$component] == $importversion) {
+                // All good here.
+                continue;
+            }
+
+            if (!$ignoreversionmismatches) {
+                if ($component === 'core') {
+                    $component = 'Moodle';
+                }
+                $versionmismatches[$component] = $component . ' (' . $importversion . ')';
+            }
+        }
+
+        // Checking that each of the components is available.
+        if (!$target = manager::get_target($modeldata->target)) {
+            $missingclasses[] = $modeldata->target;
+        }
+
+        if (!$timesplitting = manager::get_time_splitting($modeldata->timesplitting)) {
+            $missingclasses[] = $modeldata->timesplitting;
+        }
+
+        // Indicators.
+        foreach ($modeldata->indicators as $indicatorclass) {
+            if (!$indicator = manager::get_indicator($indicatorclass)) {
+                $missingclasses[] = $indicatorclass;
+            }
+        }
+
+        // ML backend.
+        if (!empty($modeldata->processor)) {
+            if (!$processor = \core_analytics\manager::get_predictions_processor($modeldata->processor, false)) {
+                $missingclasses[] = $indicatorclass;
+            }
+        }
+
+        if (!empty($missingcomponents)) {
+            return get_string('errorimportmissingcomponents', 'analytics', join(', ', $missingcomponents));
+        }
+
+        if (!empty($versionmismatches)) {
+            return get_string('errorimportversionmismatches', 'analytics', implode(', ', $versionmismatches));
+        }
+
+        if (!empty($missingclasses)) {
+            $a = (object)[
+                'missingclasses' => implode(', ', $missingclasses),
+            ];
+            return get_string('errorimportmissingclasses', 'analytics', $a);
+        }
+
+        // No issues found.
+        return null;
+    }
+
+    /**
+     * Returns the component the class belongs to.
+     *
+     * Note that this method does not work for global space classes.
+     *
+     * @param  string $fullclassname Qualified name including the namespace.
+     * @return string|null Frankenstyle component
+     */
+    public static function get_class_component(string $fullclassname) : ?string {
+
+        // Strip out leading backslash.
+        $fullclassname = ltrim($fullclassname, '\\');
+
+        $nextbackslash = strpos($fullclassname, '\\');
+        if ($nextbackslash === false) {
+            // Global space.
+            return 'core';
+        }
+        $component = substr($fullclassname, 0, $nextbackslash);
+
+        // All core subsystems use core's version.php.
+        if (strpos($component, 'core_') === 0) {
+            $component = 'core';
+        }
+
+        return $component;
+    }
+
+    /**
+     * Extracts the import zip contents.
+     *
+     * @param  string $zipfilepath Zip file path
+     * @return array [0] => \stdClass, [1] => string
+     */
+    public function extract_import_contents(string $zipfilepath) : array {
+
+        $importtempdir = make_request_directory('analyticsimport' . microtime(false));
+
+        $zip = new \zip_packer();
+        $filelist = $zip->extract_to_pathname($zipfilepath, $importtempdir);
+
+        if (empty($filelist[self::CONFIG_FILE_NAME])) {
+            // Missing required file.
+            throw new \moodle_exception('errorimport', 'analytics');
+        }
+
+        $jsonmodeldata = file_get_contents($importtempdir . DIRECTORY_SEPARATOR . self::CONFIG_FILE_NAME);
+
+        if (!$modeldata = json_decode($jsonmodeldata)) {
+            throw new \moodle_exception('errorimport', 'analytics');
+        }
+
+        if (empty($modeldata->target) || empty($modeldata->timesplitting) || empty($modeldata->indicators)) {
+            throw new \moodle_exception('errorimport', 'analytics');
+        }
+
+        $mlbackenddir = $importtempdir . DIRECTORY_SEPARATOR . 'mlbackend';
+        if (!is_dir($mlbackenddir)) {
+            $mlbackenddir = false;
+        }
+
+        return [$modeldata, $mlbackenddir];
+    }
+    /**
+     * Exports the configuration of the model.
+     * @return \stdClass
+     */
+    protected function export_model_data() : \stdClass {
+
+        $versions = \core_component::get_all_versions();
+
+        $data = new \stdClass();
+
+        // Target.
+        $data->target = $this->model->get_target()->get_id();
+        $requiredclasses[] = $data->target;
+
+        // Time splitting method.
+        $data->timesplitting = $this->model->get_time_splitting()->get_id();
+        $requiredclasses[] = $data->timesplitting;
+
+        // Model indicators.
+        $data->indicators = [];
+        foreach ($this->model->get_indicators() as $indicator) {
+            $indicatorid = $indicator->get_id();
+            $data->indicators[] = $indicatorid;
+            $requiredclasses[] = $indicatorid;
+        }
+
+        // Return the predictions processor this model is using, even if no predictions processor
+        // was explicitly selected.
+        $predictionsprocessor = $this->model->get_predictions_processor();
+        $data->processor = '\\' . get_class($predictionsprocessor);
+        $requiredclasses[] = $data->processor;
+
+        // Add information for versioning.
+        $data->dependencies = [];
+        foreach ($requiredclasses as $fullclassname) {
+            $component = $this->get_class_component($fullclassname);
+            $data->dependencies[$component] = $versions[$component];
+        }
+
+        return $data;
+    }
+}
diff --git a/analytics/classes/packable.php b/analytics/classes/packable.php
new file mode 100644 (file)
index 0000000..1d26f65
--- /dev/null
@@ -0,0 +1,57 @@
+<?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/>.
+
+/**
+ * Exportable machine learning backend interface.
+ *
+ * @package   core_analytics
+ * @copyright 2019 David Monllao {@link http://www.davidmonllao.com}
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core_analytics;
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Exportable machine learning backend interface.
+ *
+ * @package   core_analytics
+ * @copyright 2019 David Monllao {@link http://www.davidmonllao.com}
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+interface packable {
+
+    /**
+     * Exports the machine learning model.
+     *
+     * @throws \moodle_exception
+     * @param  string $uniqueid  The model unique id
+     * @param  string $modeldir  The directory that contains the trained model.
+     * @return string            The path to the directory that contains the exported model.
+     */
+    public function export(string $uniqueid, string $modeldir) : string;
+
+    /**
+     * Imports the provided machine learning model.
+     *
+     * @param  string $uniqueid The model unique id
+     * @param  string $modeldir  The directory that will contain the trained model.
+     * @param  string $importdir The directory that contains the files to import.
+     * @return bool Success
+     */
+    public function import(string $uniqueid, string $modeldir, string $importdir) : bool;
+}
index 21009ba..19ceb61 100644 (file)
@@ -28,6 +28,7 @@ require_once(__DIR__ . '/fixtures/test_indicator_max.php');
 require_once(__DIR__ . '/fixtures/test_indicator_min.php');
 require_once(__DIR__ . '/fixtures/test_indicator_fullname.php');
 require_once(__DIR__ . '/fixtures/test_target_shortname.php');
+require_once(__DIR__ . '/fixtures/test_static_target_shortname.php');
 require_once(__DIR__ . '/fixtures/test_target_course_level_shortname.php');
 require_once(__DIR__ . '/fixtures/test_analyser.php');
 
@@ -317,6 +318,95 @@ class analytics_model_testcase extends advanced_testcase {
         $this->assertLessThanOrEqual(2, $DB->count_records('analytics_used_analysables', $params));
     }
 
+    /**
+     * Test model_config::get_class_component.
+     */
+    public function test_model_config_get_class_component() {
+        $this->resetAfterTest(true);
+
+        $this->assertEquals('core',
+            \core_analytics\model_config::get_class_component('\\core\\analytics\\indicator\\read_actions'));
+        $this->assertEquals('core',
+            \core_analytics\model_config::get_class_component('core\\analytics\\indicator\\read_actions'));
+        $this->assertEquals('core',
+            \core_analytics\model_config::get_class_component('\\core_course\\analytics\\indicator\\completion_enabled'));
+        $this->assertEquals('mod_forum',
+            \core_analytics\model_config::get_class_component('\\mod_forum\\analytics\\indicator\\cognitive_depth'));
+
+        $this->assertEquals('core', \core_analytics\model_config::get_class_component('\\core_class'));
+    }
+
+    /**
+     * Test that import_model import models' configurations.
+     */
+    public function test_import_model_config() {
+        $this->resetAfterTest(true);
+
+        $this->model->enable('\\core\\analytics\\time_splitting\\quarters');
+        $zipfilepath = $this->model->export_model('yeah-config.zip');
+
+        $this->modelobj = $this->model->get_model_obj();
+
+        $importedmodelobj = \core_analytics\model::import_model($zipfilepath)->get_model_obj();
+
+        $this->assertSame($this->modelobj->target, $importedmodelobj->target);
+        $this->assertSame($this->modelobj->indicators, $importedmodelobj->indicators);
+        $this->assertSame($this->modelobj->timesplitting, $importedmodelobj->timesplitting);
+
+        $predictionsprocessor = $this->model->get_predictions_processor();
+        $this->assertSame('\\' . get_class($predictionsprocessor), $importedmodelobj->predictionsprocessor);
+    }
+
+    /**
+     * Test can export configuration
+     */
+    public function test_can_export_configuration() {
+        $this->resetAfterTest(true);
+
+        // No time splitting method.
+        $this->assertFalse($this->model->can_export_configuration());
+
+        $this->model->enable('\\core\\analytics\\time_splitting\\quarters');
+        $this->assertTrue($this->model->can_export_configuration());
+
+        $this->model->update(true, [], false);
+        $this->assertFalse($this->model->can_export_configuration());
+
+        $statictarget = new test_static_target_shortname();
+        $indicators['test_indicator_max'] = \core_analytics\manager::get_indicator('test_indicator_max');
+        $model = \core_analytics\model::create($statictarget, $indicators, '\\core\\analytics\\time_splitting\\quarters');
+        $this->assertFalse($model->can_export_configuration());
+    }
+
+    /**
+     * Test export_config
+     */
+    public function test_export_config() {
+        $this->resetAfterTest(true);
+
+        $this->model->enable('\\core\\analytics\\time_splitting\\quarters');
+
+        $modelconfig = new \core_analytics\model_config($this->model);
+
+        $method = new ReflectionMethod('\\core_analytics\\model_config', 'export_model_data');
+        $method->setAccessible(true);
+
+        $modeldata = $method->invoke($modelconfig);
+
+        $this->assertArrayHasKey('core', $modeldata->dependencies);
+        $this->assertInternalType('float', $modeldata->dependencies['core']);
+        $this->assertNotEmpty($modeldata->target);
+        $this->assertNotEmpty($modeldata->timesplitting);
+        $this->assertCount(3, $modeldata->indicators);
+
+        $indicators['test_indicator_max'] = \core_analytics\manager::get_indicator('test_indicator_max');
+        $this->model->update(true, $indicators, false);
+
+        $modeldata = $method->invoke($modelconfig);
+
+        $this->assertCount(1, $modeldata->indicators);
+    }
+
     /**
      * Generates a model log record.
      */
@@ -346,17 +436,6 @@ class analytics_model_testcase extends advanced_testcase {
  */
 class testable_model extends \core_analytics\model {
 
-    /**
-     * get_output_dir
-     *
-     * @param array $subdirs
-     * @param bool $onlymodelid
-     * @return string
-     */
-    public function get_output_dir($subdirs = array(), $onlymodelid = false) {
-        return parent::get_output_dir($subdirs, $onlymodelid);
-    }
-
     /**
      * init_analyser
      *
index 7d74868..18582c3 100644 (file)
@@ -119,23 +119,9 @@ class core_analytics_prediction_testcase extends advanced_testcase {
         $this->setAdminuser();
         set_config('enabled_stores', 'logstore_standard', 'tool_log');
 
-        $ncourses = 10;
-
         // Generate training data.
-        $params = array(
-            'startdate' => mktime(0, 0, 0, 10, 24, 2015),
-            'enddate' => mktime(0, 0, 0, 2, 24, 2016),
-        );
-        for ($i = 0; $i < $ncourses; $i++) {
-            $name = 'a' . random_string(10);
-            $courseparams = array('shortname' => $name, 'fullname' => $name) + $params;
-            $this->getDataGenerator()->create_course($courseparams);
-        }
-        for ($i = 0; $i < $ncourses; $i++) {
-            $name = 'b' . random_string(10);
-            $courseparams = array('shortname' => $name, 'fullname' => $name) + $params;
-            $this->getDataGenerator()->create_course($courseparams);
-        }
+        $ncourses = 10;
+        $this->generate_courses($ncourses);
 
         // We repeat the test for all prediction processors.
         $predictionsprocessor = \core_analytics\manager::get_predictions_processor($predictionsprocessorclass, false);
@@ -171,6 +157,10 @@ class core_analytics_prediction_testcase extends advanced_testcase {
         $this->assertEmpty($fs->get_directory_files(\context_system::instance()->id, 'analytics',
             \core_analytics\dataset_manager::UNLABELLED_FILEAREA, $model->get_id(), '/analysable/', true, false));
 
+        $params = [
+            'startdate' => mktime(0, 0, 0, 10, 24, 2015),
+            'enddate' => mktime(0, 0, 0, 2, 24, 2016),
+        ];
         $courseparams = $params + array('shortname' => 'aaaaaa', 'fullname' => 'aaaaaa', 'visible' => 0);
         $course1 = $this->getDataGenerator()->create_course($courseparams);
         $courseparams = $params + array('shortname' => 'bbbbbb', 'fullname' => 'bbbbbb', 'visible' => 0);
@@ -280,6 +270,67 @@ class core_analytics_prediction_testcase extends advanced_testcase {
         return $this->add_prediction_processors($cases);
     }
 
+    /**
+     * test_ml_export_import
+     *
+     * @param string $predictionsprocessorclass The class name
+     * @dataProvider provider_ml_export_import
+     */
+    public function test_ml_export_import($predictionsprocessorclass) {
+
+        $this->resetAfterTest(true);
+        $this->setAdminuser();
+        set_config('enabled_stores', 'logstore_standard', 'tool_log');
+
+        // Generate training data.
+        $ncourses = 10;
+        $this->generate_courses($ncourses);
+
+        // We repeat the test for all prediction processors.
+        $predictionsprocessor = \core_analytics\manager::get_predictions_processor($predictionsprocessorclass, false);
+        if ($predictionsprocessor->is_ready() !== true) {
+            $this->markTestSkipped('Skipping ' . $predictionsprocessorclass . ' as the predictor is not ready.');
+        }
+
+        $model = $this->add_perfect_model();
+        $model->update(true, false, '\core\analytics\time_splitting\quarters', get_class($predictionsprocessor));
+
+        $model->train();
+
+        $this->generate_courses(10, ['visible' => 0]);
+
+        $originalresults = $model->predict();
+
+        $zipfilename = 'model-zip-' . microtime() . '.zip';
+        $zipfilepath = $model->export_model($zipfilename);
+
+        $importmodel = \core_analytics\model::import_model($zipfilepath);
+        $importmodel->enable();
+
+        // Now predict using the imported model without prior training.
+        $importedmodelresults = $importmodel->predict();
+
+        foreach ($originalresults->predictions as $sampleid => $prediction) {
+            $this->assertEquals($importedmodelresults->predictions[$sampleid]->prediction, $prediction->prediction);
+        }
+
+        set_config('enabled_stores', '', 'tool_log');
+        get_log_manager(true);
+    }
+
+    /**
+     * provider_ml_export_import
+     *
+     * @return array
+     */
+    public function provider_ml_export_import() {
+        $cases = [
+            'case' => [],
+        ];
+
+        // We need to test all system prediction processors.
+        return $this->add_prediction_processors($cases);
+    }
     /**
      * Test the system classifiers returns.
      *
@@ -400,20 +451,7 @@ class core_analytics_prediction_testcase extends advanced_testcase {
         }
 
         // Generate training data.
-        $params = array(
-            'startdate' => mktime(0, 0, 0, 10, 24, 2015),
-            'enddate' => mktime(0, 0, 0, 2, 24, 2016),
-        );
-        for ($i = 0; $i < $ncourses; $i++) {
-            $name = 'a' . random_string(10);
-            $params = array('shortname' => $name, 'fullname' => $name) + $params;
-            $this->getDataGenerator()->create_course($params);
-        }
-        for ($i = 0; $i < $ncourses; $i++) {
-            $name = 'b' . random_string(10);
-            $params = array('shortname' => $name, 'fullname' => $name) + $params;
-            $this->getDataGenerator()->create_course($params);
-        }
+        $this->generate_courses($ncourses);
 
         // We repeat the test for all prediction processors.
         $predictionsprocessor = \core_analytics\manager::get_predictions_processor($predictionsprocessorclass, false);
@@ -579,6 +617,32 @@ class core_analytics_prediction_testcase extends advanced_testcase {
         return new \core_analytics\model($model->get_id());
     }
 
+    /**
+     * Generates $ncourses courses
+     *
+     * @param  int $ncourses The number of courses to be generated.
+     * @param  array $params Course params
+     * @return null
+     */
+    protected function generate_courses($ncourses, array $params = []) {
+
+        $params = $params + [
+            'startdate' => mktime(0, 0, 0, 10, 24, 2015),
+            'enddate' => mktime(0, 0, 0, 2, 24, 2016),
+        ];
+
+        for ($i = 0; $i < $ncourses; $i++) {
+            $name = 'a' . random_string(10);
+            $courseparams = array('shortname' => $name, 'fullname' => $name) + $params;
+            $this->getDataGenerator()->create_course($courseparams);
+        }
+        for ($i = 0; $i < $ncourses; $i++) {
+            $name = 'b' . random_string(10);
+            $courseparams = array('shortname' => $name, 'fullname' => $name) + $params;
+            $this->getDataGenerator()->create_course($courseparams);
+        }
+    }
+
     /**
      * add_prediction_processors
      *
index b3d5322..149f606 100644 (file)
@@ -485,6 +485,9 @@ class restore_gradebook_structure_step extends restore_structure_step {
         // Freeze gradebook calculations if needed.
         $this->gradebook_calculation_freeze();
 
+        // Ensure the module cache is current when recalculating grades.
+        rebuild_course_cache($this->get_courseid(), true);
+
         // Restore marks items as needing update. Update everything now.
         grade_regrade_final_grades($this->get_courseid());
     }
index 68cb5c6..7c03084 100644 (file)
@@ -145,3 +145,41 @@ Feature: Add badges to the system
       | Description | Test Badge Competencies description |
     When I press "Save changes"
     And I should see "Competencies (1)"
+
+  @javascript @_file_upload
+  Scenario: Add a badge from Site badges section
+    Given I press "Customise this page"
+    # TODO MDL-57120 site "Badges" link not accessible without navigation block.
+    And I add the "Navigation" block if not present
+    When I click on "Site pages" "list_item" in the "Navigation" "block"
+    And I click on "Site badges" "link" in the "Navigation" "block"
+    Then I should see "Manage badges"
+    And I should see "Add a new badge"
+    # Add a badge.
+    When I press "Add a new badge"
+    And I set the following fields to these values:
+      | Name | Test badge with 'apostrophe' and other friends (<>&@#) 2 |
+      | Version | v1 |
+      | Language | English |
+      | Description | Test badge description |
+      | Image author | http://author.example.com |
+      | Image caption | Test caption image |
+      | issuername | Test Badge Site |
+      | issuercontact | testuser@example.com |
+    And I upload "badges/tests/behat/badge.png" file to "Image" filemanager
+    And I press "Create badge"
+    Then I should see "Edit details"
+    And I should see "Test badge with 'apostrophe' and other friends (&@#) 2"
+    And I should see "Endorsement"
+    And I should see "Related badges (0)"
+    And I should see "Competencies (0)"
+    And I should not see "Create badge"
+    And I follow "Manage badges"
+    And I should see "Number of badges available: 1"
+    And I should not see "There are no badges available."
+    # See buttons from the "Site badges" page.
+    And I am on homepage
+    When I click on "Site pages" "list_item" in the "Navigation" "block"
+    And I click on "Site badges" "link" in the "Navigation" "block"
+    Then I should see "Manage badges"
+    And I should see "Add a new badge"
index 79ae795..079b2c6 100644 (file)
@@ -69,6 +69,31 @@ Feature: Award badges
     When I click on "Course 1" "link" in the "region-main" "region"
     Then I should see "Course Badge 1"
     And I should see "Course Badge 2"
+    # Student 1 should have both badges also in the Badges navigation section.
+    When I follow "Badges"
+    Then I should see "Course Badge 1"
+    And I should see "Course Badge 2"
+    And I should not see "Manage badges"
+    And I should not see "Add a new badge"
+    And I log out
+    # Teacher 1 should have access to manage/create badges in the Badges navigation section.
+    When I log in as "teacher1"
+    And I am on "Course 1" course homepage
+    And I follow "Badges"
+    Then I should see "Course Badge 1"
+    And I should see "Course Badge 2"
+    And I should see "Manage badges"
+    And I should see "Add a new badge"
+    # Teacher 1 should NOT have access to manage/create site badges in the Site badges section.
+    When I am on homepage
+    And I press "Customise this page"
+   # TODO MDL-57120 site "Badges" link not accessible without navigation block.
+    And I add the "Navigation" block if not present
+    And I click on "Site pages" "list_item" in the "Navigation" "block"
+    And I click on "Site badges" "link" in the "Navigation" "block"
+    Then I should see "There are no badges available."
+    And I should not see "Manage badges"
+    And I should not see "Add a new badge"
 
   @javascript
   Scenario: Award profile badge
index 704af1f..f7d3fcb 100644 (file)
@@ -106,6 +106,32 @@ if ($totalcount) {
 } else {
     echo $output->notification(get_string('nobadges', 'badges'));
 }
+
+// Display "Manage badges" button to users with proper capabilities.
+$isfrontpage = (empty($courseid) || $courseid == $SITE->id);
+if ($isfrontpage) {
+    $context = context_system::instance();
+} else {
+    $context = context_course::instance($courseid);
+}
+$canmanage = has_any_capability(array('moodle/badges:viewawarded',
+                                      'moodle/badges:createbadge',
+                                      'moodle/badges:awardbadge',
+                                      'moodle/badges:configurecriteria',
+                                      'moodle/badges:configuremessages',
+                                      'moodle/badges:configuredetails',
+                                      'moodle/badges:deletebadge'), $context);
+if ($canmanage) {
+    echo $output->single_button(new moodle_url('/badges/index.php', array('type' => $type, 'id' => $courseid)),
+        get_string('managebadges', 'badges'));
+}
+
+// Display "Add new badge" button to users with capability to create badges.
+if (has_capability('moodle/badges:createbadge', $PAGE->context)) {
+    echo $output->single_button(new moodle_url('newbadge.php', array('type' => $type, 'id' => $courseid)),
+        get_string('newbadge', 'badges'));
+}
+
 // Trigger event, badge listing viewed.
 $eventparams = array('context' => $PAGE->context, 'other' => $eventotherparams);
 $event = \core\event\badge_listing_viewed::create($eventparams);
index faa02a4..5d6f205 100644 (file)
@@ -6,7 +6,7 @@ Feature: Add a bookmarks to an admin pages
 
   Background:
     Given I log in as "admin"
-    And I navigate to "Server > Scheduled tasks" in site administration
+    And I navigate to "Server > Tasks > Scheduled tasks" in site administration
     And I click on "Bookmark this page" "link" in the "Admin bookmarks" "block"
     And I log out
 
index 99b59c4..9d6dfd0 100644 (file)
Binary files a/blocks/myoverview/amd/build/view.min.js and b/blocks/myoverview/amd/build/view.min.js differ
index 85c81e1..747d33f 100644 (file)
@@ -217,7 +217,7 @@ function(
 
         setCourseFavouriteState(courseId, true).then(function(success) {
             if (success) {
-                PubSub.publish(CourseEvents.favourited);
+                PubSub.publish(CourseEvents.favourited, courseId);
                 removeAction.removeClass('hidden');
                 addAction.addClass('hidden');
                 showFavouriteIcon(root, courseId);
@@ -240,7 +240,7 @@ function(
 
         setCourseFavouriteState(courseId, false).then(function(success) {
             if (success) {
-                PubSub.publish(CourseEvents.unfavorited);
+                PubSub.publish(CourseEvents.unfavorited, courseId);
                 removeAction.addClass('hidden');
                 addAction.removeClass('hidden');
                 hideFavouriteIcon(root, courseId);
index 22c49e3..ed51a17 100644 (file)
  */
 
 $string['all'] = 'All';
+$string['allexcepthidden'] = 'All (except hidden)';
 $string['addtofavourites'] = 'Star this course';
 $string['aria:addtofavourites'] = 'Star for';
 $string['aria:allcourses'] = 'All courses';
+$string['aria:allcoursesexcepthidden'] = 'All courses except hidden courses';
 $string['aria:card'] = 'Switch to card view';
 $string['aria:controls'] = 'Course overview controls';
 $string['aria:courseactions'] = 'Actions for current course';
index 687e1a3..45605eb 100644 (file)
@@ -31,7 +31,7 @@
     <button id="groupingdropdown" type="button" class="btn btn-outline-secondary dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false" aria-label="{{#str}} aria:groupingdropdown, block_myoverview {{/str}}">
         {{#pix}} i/filter {{/pix}}
         <span class="d-sm-inline-block" data-active-item-text>
-            {{#all}}{{#str}} all, block_myoverview {{/str}}{{/all}}
+            {{#all}}{{#str}} allexcepthidden, block_myoverview {{/str}}{{/all}}
             {{#inprogress}}{{#str}} inprogress, block_myoverview {{/str}}{{/inprogress}}
             {{#future}}{{#str}} future, block_myoverview {{/str}}{{/future}}
             {{#past}}{{#str}} past, block_myoverview {{/str}}{{/past}}
     </button>
     <ul class="dropdown-menu" data-show-active-item data-active-item-text aria-labelledby="groupingdropdown">
         <li>
-            <a class="dropdown-item {{#all}}active{{/all}}" href="#" data-filter="grouping" data-value="all" data-pref="all" aria-label="{{#str}} aria:allcourses, block_myoverview {{/str}}" aria-controls="courses-view-{{uniqid}}">
-                {{#str}} all, block_myoverview {{/str}}
+            <a class="dropdown-item {{#all}}active{{/all}}" href="#" data-filter="grouping" data-value="all" data-pref="all" aria-label="{{#str}} aria:allcoursesexcepthidden, block_myoverview {{/str}}" aria-controls="courses-view-{{uniqid}}">
+                {{#str}} allexcepthidden, block_myoverview {{/str}}
             </a>
         </li>
+        <li class="dropdown-divider" role="presentation">
+            <span class="filler">&nbsp;</span>
+        </li>
         <li>
             <a class="dropdown-item {{#inprogress}}active{{/inprogress}}" href="#" data-filter="grouping" data-value="inprogress" data-pref="inprogress" aria-label="{{#str}} aria:inprogress, block_myoverview {{/str}}" aria-controls="courses-view-{{uniqid}}">
                 {{#str}} inprogress, block_myoverview {{/str}}
                 {{#str}} past, block_myoverview {{/str}}
             </a>
         </li>
+        <li class="dropdown-divider" role="presentation">
+            <span class="filler">&nbsp;</span>
+        </li>
         <li>
             <a class="dropdown-item {{#favourites}}active{{/favourites}}" href="#" data-filter="grouping" data-value="favourites"  data-pref="favourites" aria-label="{{#str}} aria:favourites, block_myoverview {{/str}}" aria-controls="courses-view-{{uniqid}}">
                 {{#str}} favourites, block_myoverview {{/str}}
             </a>
         </li>
+        <li class="dropdown-divider" role="presentation">
+            <span class="filler">&nbsp;</span>
+        </li>
         <li>
             <a class="dropdown-item {{#hidden}}active{{/hidden}}" href="#" data-filter="grouping" data-value="hidden"  data-pref="hidden" aria-label="{{#str}} aria:hiddencourses, block_myoverview {{/str}}" aria-controls="courses-view-{{uniqid}}">
                 {{#str}} hiddencourses, block_myoverview {{/str}}
index 594bbde..e76490a 100644 (file)
Binary files a/blocks/recentlyaccessedcourses/amd/build/main.min.js and b/blocks/recentlyaccessedcourses/amd/build/main.min.js differ
index 2fdd54c..6a3ca16 100644 (file)
 define(
     [
         'jquery',
-        'core_course/repository',
-        'core/templates',
+        'core/custom_interaction_events',
         'core/notification',
         'core/pubsub',
-        'core_course/events'
+        'core/paged_content_paging_bar',
+        'core/templates',
+        'core_course/events',
+        'core_course/repository',
     ],
     function(
         $,
-        CoursesRepository,
-        Templates,
+        CustomEvents,
         Notification,
         PubSub,
-        CourseEvents
+        PagedContentPagingBar,
+        Templates,
+        CourseEvents,
+        CoursesRepository
     ) {
 
+        // Constants.
+        var NUM_COURSES_TOTAL = 10;
         var SELECTORS = {
-            COURSES_VIEW: '[data-region="recentlyaccessedcourses-view"]',
-            COURSES_VIEW_CONTENT: '[data-region="recentlyaccessedcourses-view-content"]'
+            CARD_CONTAINER: '[data-region="card-deck"]',
+            COURSE_IS_FAVOURITE: '[data-region="is-favourite"]',
+            CONTENT: '[data-region="view-content"]',
+            EMPTY_MESSAGE: '[data-region="empty-message"]',
+            LOADING_PLACEHOLDER: '[data-region="loading-placeholder"]',
+            PAGING_BAR: '[data-region="paging-bar"]',
+            PAGING_BAR_NEXT: '[data-control="next"]',
+            PAGING_BAR_PREVIOUS: '[data-control="previous"]'
         };
+        // Module variables.
+        var contentLoaded = false;
+        var allCourses = [];
+        var visibleCoursesId = null;
+        var cardWidth = null;
+        var viewIndex = 0;
+        var availableVisibleCards = 1;
 
-        var NUM_COURSES_TOTAL = 10;
+        /**
+         * Show the empty message when no course are found.
+         *
+         * @param {object} root The root element for the courses view.
+         */
+        var showEmptyMessage = function(root) {
+            root.find(SELECTORS.EMPTY_MESSAGE).removeClass('hidden');
+            root.find(SELECTORS.LOADING_PLACEHOLDER).addClass('hidden');
+            root.find(SELECTORS.CONTENT).addClass('hidden');
+        };
 
         /**
-         * Get enrolled courses from backend.
+         * Show the empty message when no course are found.
          *
-         * @method getRecentCourses
-         * @param {int} userid User from which the courses will be obtained
-         * @param {int} limit Only return this many results
-         * @return {array} Courses user has accessed
+         * @param {object} root The root element for the courses view.
          */
-        var getRecentCourses = function(userid, limit) {
-            return CoursesRepository.getLastAccessedCourses(userid, limit);
+        var showContent = function(root) {
+            root.find(SELECTORS.CONTENT).removeClass('hidden');
+            root.find(SELECTORS.EMPTY_MESSAGE).addClass('hidden');
+            root.find(SELECTORS.LOADING_PLACEHOLDER).addClass('hidden');
         };
 
         /**
-         * Render the dashboard courses.
+         * Show the paging bar.
          *
-         * @method renderCourses
          * @param {object} root The root element for the courses view.
-         * @param {array} courses containing array of returned courses.
-         * @return {promise} Resolved with HTML and JS strings
          */
-        var renderCourses = function(root, courses) {
-            if (courses.length > 0) {
-                return Templates.render('core_course/view-cards', {
-                    courses: courses
-                });
-            } else {
-                var nocoursesimgurl = root.attr('data-nocoursesimg');
-                return Templates.render('block_recentlyaccessedcourses/no-courses', {
-                    nocoursesimg: nocoursesimgurl
+        var showPagingBar = function(root) {
+            var pagingBar = root.find(SELECTORS.PAGING_BAR);
+            pagingBar.css('opacity', 1);
+            pagingBar.css('visibility', 'visible');
+            pagingBar.attr('aria-hidden', 'false');
+        };
+
+        /**
+         * Hide the paging bar.
+         *
+         * @param {object} root The root element for the courses view.
+         */
+        var hidePagingBar = function(root) {
+            var pagingBar = root.find(SELECTORS.PAGING_BAR);
+            pagingBar.css('opacity', 0);
+            pagingBar.css('visibility', 'hidden');
+            pagingBar.attr('aria-hidden', 'true');
+        };
+
+        /**
+         * Show the favourite indicator for the given course (if it's in the list).
+         *
+         * @param {object} root The root element for the courses view.
+         * @param {number} courseId The id of the course to be favourited.
+         */
+        var favouriteCourse = function(root, courseId) {
+            allCourses.forEach(function(course) {
+                if (course.attr('data-course-id') == courseId) {
+                    course.find(SELECTORS.COURSE_IS_FAVOURITE).removeClass('hidden');
+                }
+            });
+        };
+
+        /**
+         * Hide the favourite indicator for the given course (if it's in the list).
+         *
+         * @param {object} root The root element for the courses view.
+         * @param {number} courseId The id of the course to be unfavourited.
+         */
+        var unfavouriteCourse = function(root, courseId) {
+            allCourses.forEach(function(course) {
+                if (course.attr('data-course-id') == courseId) {
+                    course.find(SELECTORS.COURSE_IS_FAVOURITE).addClass('hidden');
+                }
+            });
+        };
+
+        /**
+         * Render the a list of courses.
+         *
+         * @param {array} courses containing array of courses.
+         * @return {promise} Resolved with list of rendered courses as jQuery objects.
+         */
+        var renderAllCourses = function(courses) {
+            var promises = courses.map(function(course) {
+                return Templates.render('block_recentlyaccessedcourses/course-card', course);
+            });
+
+            return $.when.apply(null, promises).then(function() {
+                var renderedCourses = [];
+
+                promises.forEach(function(promise) {
+                    promise.then(function(html) {
+                        renderedCourses.push($(html));
+                        return;
+                    })
+                    .catch(Notification.exception);
                 });
-            }
+
+                return renderedCourses;
+            });
         };
 
         /**
          * Fetch user's recently accessed courses and reload the content of the block.
          *
          * @param {int} userid User whose courses will be shown
-         * @param {object} root The root element for the recentlyaccessedcourses view.
          * @returns {promise} The updated content for the block.
          */
-        var reloadContent = function(userid, root) {
+        var loadContent = function(userid) {
+            return CoursesRepository.getLastAccessedCourses(userid, NUM_COURSES_TOTAL)
+                .then(function(courses) {
+                    return renderAllCourses(courses);
+                });
+        };
+
+        /**
+         * Recalculate the number of courses that should be visible.
+         *
+         * @param {object} root The root element for the courses view.
+         */
+        var recalculateVisibleCourses = function(root) {
+            var container = root.find(SELECTORS.CONTENT).find(SELECTORS.CARD_CONTAINER);
+            var availableWidth = parseFloat(root.css('width'));
+            var numberOfCourses = allCourses.length;
+            var start = 0;
+
+            if (!cardWidth) {
+                container.html(allCourses[0]);
+                // Render one card initially to calculate the width of the cards
+                // including the margins.
+                cardWidth = allCourses[0].outerWidth(true);
+            }
+
+            availableVisibleCards = Math.floor(availableWidth / cardWidth);
+
+            if (viewIndex + availableVisibleCards < numberOfCourses) {
+                start = viewIndex;
+            } else {
+                var overflow = (viewIndex + availableVisibleCards) - numberOfCourses;
+                start = viewIndex - overflow;
+                start = start >= 0 ? start : 0;
+            }
+
+            var coursesToShow = allCourses.slice(start, start + availableVisibleCards);
+            // Create an id for the list of courses we expect to be displayed.
+            var newVisibleCoursesId = coursesToShow.reduce(function(carry, course) {
+                return carry + course.attr('data-course-id');
+            }, '');
+
+            // Centre the courses if we have an overflow of courses.
+            if (allCourses.length > coursesToShow.length) {
+                container.addClass('justify-content-center');
+                container.removeClass('justify-content-start');
+            } else {
+                container.removeClass('justify-content-center');
+                container.addClass('justify-content-start');
+            }
 
-            var recentcoursesViewRoot = root.find(SELECTORS.COURSES_VIEW);
-            var recentcoursesViewContent = root.find(SELECTORS.COURSES_VIEW_CONTENT);
+            // Don't bother updating the DOM unless the visible courses have changed.
+            if (visibleCoursesId != newVisibleCoursesId) {
+                var pagingBar = root.find(PagedContentPagingBar.rootSelector);
+                container.html(coursesToShow);
+                visibleCoursesId = newVisibleCoursesId;
 
-            var coursesPromise = getRecentCourses(userid, NUM_COURSES_TOTAL);
+                if (availableVisibleCards >= allCourses.length) {
+                    hidePagingBar(root);
+                } else {
+                    showPagingBar(root);
 
-            return coursesPromise.then(function(courses) {
-                var pagedContentPromise = renderCourses(recentcoursesViewRoot, courses);
+                    if (viewIndex === 0) {
+                        PagedContentPagingBar.disablePreviousControlButtons(pagingBar);
+                    } else {
+                        PagedContentPagingBar.enablePreviousControlButtons(pagingBar);
+                    }
 
-                pagedContentPromise.then(function(html, js) {
-                    return Templates.replaceNodeContents(recentcoursesViewContent, html, js);
-                }).catch(Notification.exception);
-                return coursesPromise;
-            }).catch(Notification.exception);
+                    if (viewIndex + availableVisibleCards >= allCourses.length) {
+                        PagedContentPagingBar.disableNextControlButtons(pagingBar);
+                    } else {
+                        PagedContentPagingBar.enableNextControlButtons(pagingBar);
+                    }
+                }
+            }
         };
 
         /**
          * Register event listeners for the block.
          *
-         * @param {int} userid User whose courses will be shown
          * @param {object} root The root element for the recentlyaccessedcourses block.
          */
-        var registerEventListeners = function(userid, root) {
-            PubSub.subscribe(CourseEvents.favourited, function() {
-                reloadContent(userid, root);
+        var registerEventListeners = function(root) {
+            var resizeTimeout = null;
+            var drawerToggling = false;
+
+            PubSub.subscribe(CourseEvents.favourited, function(courseId) {
+                favouriteCourse(root, courseId);
             });
 
-            PubSub.subscribe(CourseEvents.unfavorited, function() {
-                reloadContent(userid, root);
+            PubSub.subscribe(CourseEvents.unfavorited, function(courseId) {
+                unfavouriteCourse(root, courseId);
+            });
+
+            PubSub.subscribe('nav-drawer-toggle-start', function() {
+                if (!contentLoaded || !allCourses.length || drawerToggling) {
+                    // Nothing to recalculate.
+                    return;
+                }
+
+                drawerToggling = true;
+                var recalculationCount = 0;
+                // This function is going to recalculate the number of courses while
+                // the nav drawer is opening or closes (up to a maximum of 5 recalcs).
+                var doRecalculation = function() {
+                    setTimeout(function() {
+                        recalculateVisibleCourses(root);
+                        recalculationCount++;
+
+                        if (recalculationCount < 5 && drawerToggling) {
+                            // If we haven't done too many recalculations and the drawer
+                            // is still toggling then recurse.
+                            doRecalculation();
+                        }
+                    }, 100);
+                };
+
+                // Start the recalculations.
+                doRecalculation(root);
+            });
+
+            PubSub.subscribe('nav-drawer-toggle-end', function() {
+                drawerToggling = false;
+            });
+
+            $(window).on('resize', function() {
+                if (!contentLoaded || !allCourses.length) {
+                    // Nothing to reclculate.
+                    return;
+                }
+
+                // Resize events fire rapidly so recalculating the visible courses each
+                // time can be expensive. Let's debounce them,
+                if (!resizeTimeout) {
+                    resizeTimeout = setTimeout(function() {
+                        resizeTimeout = null;
+                        recalculateVisibleCourses(root);
+                    // The recalculateVisibleCourses function will execute at a rate of 15fps.
+                    }, 66);
+                }
+            });
+
+            CustomEvents.define(root, [CustomEvents.events.activate]);
+            root.on(CustomEvents.events.activate, SELECTORS.PAGING_BAR_NEXT, function(e, data) {
+                var button = $(e.target).closest(SELECTORS.PAGING_BAR_NEXT);
+                if (!button.hasClass('disabled')) {
+                    viewIndex = viewIndex + availableVisibleCards;
+                    recalculateVisibleCourses(root);
+                }
+
+                data.originalEvent.preventDefault();
+            });
+
+            root.on(CustomEvents.events.activate, SELECTORS.PAGING_BAR_PREVIOUS, function(e, data) {
+                var button = $(e.target).closest(SELECTORS.PAGING_BAR_PREVIOUS);
+                if (!button.hasClass('disabled')) {
+                    viewIndex = viewIndex - availableVisibleCards;
+                    viewIndex = viewIndex < 0 ? 0 : viewIndex;
+                    recalculateVisibleCourses(root);
+                }
+
+                data.originalEvent.preventDefault();
             });
         };
 
@@ -129,8 +343,22 @@ define(
         var init = function(userid, root) {
             root = $(root);
 
-            registerEventListeners(userid, root);
-            reloadContent(userid, root);
+            registerEventListeners(root);
+            loadContent(userid)
+                .then(function(renderedCourses) {
+                    allCourses = renderedCourses;
+                    contentLoaded = true;
+
+                    if (allCourses.length) {
+                        showContent(root);
+                        recalculateVisibleCourses(root);
+                    } else {
+                        showEmptyMessage(root);
+                    }
+
+                    return;
+                })
+                .catch(Notification.exception);
         };
 
         return {
index 06eb282..631af28 100644 (file)
@@ -45,11 +45,15 @@ class main implements renderable, templatable {
     public function export_for_template(renderer_base $output) {
         global $USER;
 
-        $nocoursesurl = $output->image_url('courses', 'block_recentlyaccessedcourses')->out();
+        $nocoursesurl = $output->image_url('courses', 'block_recentlyaccessedcourses')->out(false);
 
         return [
             'userid' => $USER->id,
-            'nocoursesimg' => $nocoursesurl
+            'nocoursesimgurl' => $nocoursesurl,
+            'pagingbar' => [
+                'next' => true,
+                'previous' => true
+            ]
         ];
     }
 }
diff --git a/blocks/recentlyaccessedcourses/templates/course-card.mustache b/blocks/recentlyaccessedcourses/templates/course-card.mustache
new file mode 100644 (file)
index 0000000..7b98a5e
--- /dev/null
@@ -0,0 +1,44 @@
+{{!
+    This file is part of Moodle - http://moodle.org/
+
+    Moodle is free software: you can redistribute it and/or modify
+    it under the terms of the GNU General Public License as published by
+    the Free Software Foundation, either version 3 of the License, or
+    (at your option) any later version.
+
+    Moodle is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+    GNU General Public License for more details.
+
+    You should have received a copy of the GNU General Public Licensebllsdsadfasfd
+    along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+}}
+{{!
+    @template block_recentlyaccessedcourses/course-card
+
+    This template renders a course for the recentlyaccessedcourses block.
+
+    Example context (json):
+    {
+        "courses": [
+            {
+                "name": "Assignment due 1",
+                "viewurl": "https://moodlesite/course/view.php?id=2",
+                "courseimageurl": "https://moodlesite/pluginfile/123/course/overviewfiles/123.jpg",
+                "fullname": "course 3",
+                "isfavourite": true
+            }
+        ]
+    }
+}}
+
+{{< core_course/coursecard }}
+    {{$coursecategory}}
+        <span class="sr-only">
+            {{#str}}aria:coursecategory, core_course{{/str}}
+        </span>
+        <span class="text-truncate">{{{coursecategory}}}</span>
+    {{/coursecategory}}
+    {{$coursename}} <span class="text-truncate">{{{fullname}}}</span> {{/coursename}}
+{{/ core_course/coursecard }}
diff --git a/blocks/recentlyaccessedcourses/templates/no-courses.mustache b/blocks/recentlyaccessedcourses/templates/no-courses.mustache
deleted file mode 100644 (file)
index cf1b8b0..0000000
+++ /dev/null
@@ -1,21 +0,0 @@
-{{!
-    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/>.
-}}
-{{< core_course/no-courses}}
-    {{$nocoursestring}}
-        {{#str}} nocourses, block_recentlyaccessedcourses {{/str}}
-    {{/nocoursestring}}
-{{/ core_course/no-courses}}
index ec44dae..bb894d7 100644 (file)
 
     Example context (json):
     {
-        "nocoursesimg": "https://moodlesite/theme/image.php/boost/block_recentlyaccessedcourses/1535727318/courses"
     }
 }}
-<div id="recentlyaccessedcourses-view-{{uniqid}}"
-     data-region="recentlyaccessedcourses-view"
-     data-nocoursesimg="{{nocoursesimg}}">
-    <div data-region="recentlyaccessedcourses-view-content">
-        <div data-region="recentlyaccessedcourses-loading-placeholder">
-            <div class="card-deck dashboard-card-deck one-row" style="height: 11.1rem">
-                {{> core_course/placeholder-course }}
-                {{> core_course/placeholder-course }}
-                {{> core_course/placeholder-course }}
-                {{> core_course/placeholder-course }}
-            </div>
+<div id="recentlyaccessedcourses-view-{{uniqid}}" data-region="recentlyaccessedcourses-view">
+    <div data-region="loading-placeholder">
+        <div class="card-deck dashboard-card-deck one-row overflow-hidden" style="height: 13.05rem">
+            {{> core_course/placeholder-course }}
+            {{> core_course/placeholder-course }}
+            {{> core_course/placeholder-course }}
+            {{> core_course/placeholder-course }}
         </div>
     </div>
-</div>
\ No newline at end of file
+    <div class="hidden" data-region="view-content">
+        {{#pagingbar}}
+            <div class="d-flex paging-bar-container mb-3" data-region="paging-bar-container">
+                {{> core/paged_content_paging_bar }}
+            </div>
+        {{/pagingbar}}
+        {{< core_course/coursecards }}
+            {{$classes}}one-row fixed-width-cards justify-content-center overflow-hidden{{/classes}}
+        {{/ core_course/coursecards }}
+    </div>
+    <div class="hidden text-xs-center text-center m-t-3" data-region="empty-message">
+        <img class="empty-placeholder-image-lg m-t-1"
+            src="{{nocoursesimgurl}}"
+            alt="{{#str}} nocourses, block_recentlyaccessedcourses {{/str}}"
+            role="presentation">
+        <p class="text-muted mt-3">{{#str}} nocourses, block_recentlyaccessedcourses {{/str}}</p>
+    </div>
+</div>
index 9f0411f..0dc8b87 100644 (file)
@@ -1,15 +1,18 @@
 <div class="searchform">
-    <form action="{{actionurl}}" style="display: inline;">
-        <fieldset class="invisiblefieldset">
-            <legend class="accesshide">{{#str}}search{{/str}}</legend>
-            <input type="hidden" name="id" value="{{courseid}}">
-            <label class="accesshide" for="searchform_search">{{#str}}search{{/str}}</label>
-            <input id="searchform_search" name="search" type="text" size="16">
-            <button id="searchform_button" type="submit" title={{#quote}}{{#str}}search{{/str}}{{/quote}}>{{#str}}go{{/str}}</button><br>
-            <a href="{{advancedsearchurl}}">{{#str}}advancedsearch, block_search_forums{{/str}}</a>
-            {{#helpicon}}
-                {{>core/help_icon}}
-            {{/helpicon}}
-        </fieldset>
+    <form action="{{actionurl}}" class="form-inline">
+        <input type="hidden" name="id" value="{{courseid}}">
+        <div class="input-group w-100">
+            <label class="sr-only" for="searchform_search">{{#str}}search{{/str}}</label>
+            <input id="searchform_search" name="search" type="text" class="form-control" size="10">
+            <div class="input-group-append">
+                <button class="btn btn-secondary" id="searchform_button" type="submit">{{#str}}go{{/str}}</button>
+            </div>
+        </div>
     </form>
+    <div class="mt-3">
+        <a href="{{advancedsearchurl}}">{{#str}}advancedsearch, block_search_forums{{/str}}</a>
+        {{#helpicon}}
+            {{>core/help_icon}}
+        {{/helpicon}}
+    </div>
 </div>
index 34e99ef..bceefd8 100644 (file)
@@ -143,14 +143,12 @@ class block_settings_renderer extends plugin_renderer_base {
     }
 
     public function search_form(moodle_url $formtarget, $searchvalue) {
-        $content = html_writer::start_tag('form', array('class'=>'adminsearchform', 'method'=>'get', 'action'=>$formtarget, 'role' => 'search'));
-        $content .= html_writer::start_tag('div');
-        $content .= html_writer::tag('label', s(get_string('searchinsettings', 'admin')), array('for'=>'adminsearchquery', 'class'=>'accesshide'));
-        $content .= html_writer::empty_tag('input', array('id'=>'adminsearchquery', 'type'=>'text', 'name'=>'query', 'value'=>s($searchvalue)));
-        $content .= html_writer::empty_tag('input', array('type'=>'submit', 'value'=>s(get_string('search'))));
-        $content .= html_writer::end_tag('div');
-        $content .= html_writer::end_tag('form');
-        return $content;
+        $data = [
+                'action' => $formtarget->out(false),
+                'label' => get_string('searchinsettings', 'admin'),
+                'searchvalue' => $searchvalue
+        ];
+        return $this->render_from_template('block_settings/search_form', $data);
     }
 
 }
index b05d84c..5f55ca2 100644 (file)
@@ -46,15 +46,13 @@ class behat_blocks extends behat_base {
      * @param string $blockname
      */
     public function i_add_the_block($blockname) {
-        $this->execute('behat_forms::i_set_the_field_to',
-            array("bui_addblock", $this->escape($blockname))
-        );
+        $addblock = get_string('addblock');
+        $this->execute('behat_navigation::i_select_from_flat_navigation_drawer', $addblock);
 
-        // If we are running without javascript we need to submit the form.
         if (!$this->running_javascript()) {
-            $this->execute('behat_general::i_click_on_in_the',
-                array(get_string('go'), "button", "#add_block", "css_element")
-            );
+            $this->execute('behat_general::i_click_on_in_the', [$blockname, 'link_exact', '#region-main', 'css_element']);
+        } else {
+            $this->execute('behat_general::i_click_on_in_the', [$blockname, 'link_exact', $addblock, 'dialogue']);
         }
     }
 
@@ -108,7 +106,7 @@ class behat_blocks extends behat_base {
         }
 
         $this->execute('behat_general::i_click_on_in_the',
-            array(get_string('actions'), "link", $this->escape($blockname), "block")
+                array("a[data-toggle='dropdown']", "css_element", $this->escape($blockname), "block")
         );
     }
 
@@ -137,7 +135,17 @@ class behat_blocks extends behat_base {
      * @param string $blockname
      */
     public function the_add_block_selector_should_contain_block($blockname) {
-        $this->execute('behat_forms::the_select_box_should_contain', [get_string('addblock'), $blockname]);
+        $addblock = get_string('addblock');
+        $this->execute('behat_navigation::i_select_from_flat_navigation_drawer', $addblock);
+
+        $cancelstr = get_string('cancel');
+        if (!$this->running_javascript()) {
+            $this->execute('behat_general::should_exist_in_the', [$blockname, 'link_exact', '#region-main', 'css_element']);
+            $this->execute('behat_general::i_click_on_in_the', [$cancelstr, 'link_exact', '#region-main', 'css_element']);
+        } else {
+            $this->execute('behat_general::should_exist_in_the', [$blockname, 'link_exact', $addblock, 'dialogue']);
+            $this->execute('behat_general::i_click_on_in_the', [$cancelstr, 'button', $addblock, 'dialogue']);
+        }
     }
 
     /**
@@ -147,6 +155,16 @@ class behat_blocks extends behat_base {
      * @param string $blockname
      */
     public function the_add_block_selector_should_not_contain_block($blockname) {
-        $this->execute('behat_forms::the_select_box_should_not_contain', [get_string('addblock'), $blockname]);
+        $addblock = get_string('addblock');
+        $this->execute('behat_navigation::i_select_from_flat_navigation_drawer', $addblock);
+
+        $cancelstr = get_string('cancel');
+        if (!$this->running_javascript()) {
+            $this->execute('behat_general::should_not_exist_in_the', [$blockname, 'link_exact', '#region-main', 'css_element']);
+            $this->execute('behat_general::i_click_on_in_the', [$cancelstr, 'link_exact', '#region-main', 'css_element']);
+        } else {
+            $this->execute('behat_general::should_not_exist_in_the', [$blockname, 'link_exact', $addblock, 'dialogue']);
+            $this->execute('behat_general::i_click_on_in_the', [$cancelstr, 'button', $addblock, 'dialogue']);
+        }
     }
 }
index d6c91af..072345d 100644 (file)
         "day": "Today",
         "url": "http://example.com/",
         "title": "Monday 2nd January",
-        "content": "<img class='icon smallicon' alt='icon' src='../../../pix/i/siteevent.svg'>Test site event"
+        "content": "<img class='icon smallicon' src='../../../pix/i/siteevent.svg'>Test site event"
     }
 }}
-{{< core/hover_tooltip }}
-    {{$anchor}}
-        <a href="{{url}}">{{$day}}{{day}}{{/day}}</a>
-    {{/anchor}}
-    {{$tooltip}}
-        <b>{{$title}}{{title}}{{/title}}</b>
-        {{$content}}{{{content}}}{{/content}}
-    {{/tooltip}}
-{{/ core/hover_tooltip }}
+<a {{!
+    }} id="calendar-day-popover-link-{{courseid}}-{{year}}-{{yday}}-{{uniqid}}"{{!
+    }} href="{{$url}}{{url}}{{/url}}"{{!
+    }} data-container="body"{{!
+    }} data-toggle="popover"{{!
+    }} data-html="true"{{!
+    }} data-trigger="hover"{{!
+    }} data-placement="top"{{!
+    }} data-title="{{$title}}{{title}}{{/title}}"{{!
+    }} data-alternate="{{$nocontent}}{{/nocontent}}"{{!
+}}>{{$day}}{{day}}{{/day}}</a>
+<div class="hidden">
+    {{$content}}{{/content}}
+</div>
+{{#js}}
+require(['jquery'], function($) {
+    require(['theme_boost/popover'], function() {
+        var target = $("#calendar-day-popover-link-{{courseid}}-{{year}}-{{yday}}-{{uniqid}}");
+        target.popover({
+            content: function() {
+                var source = target.next().find("> *:not('.hidden')");
+                var content = $('<div>');
+
+                if (source.length) {
+                    content.html(source.clone(false));
+                } else {
+                    content.html(target.data('alternate'));
+                }
+
+                return content.html();
+            }
+        });
+    });
+});
+{{/js}}
index 97edf63..6c7ff90 100644 (file)
@@ -5015,8 +5015,9 @@ class api {
         static::require_enabled();
         $coursecontext = context_course::instance($courseid);
 
-        if (!has_any_capability(array('moodle/competency:competencyview', 'moodle/competency:competencymanage'), $coursecontext)) {
-            throw new required_capability_exception($coursecontext, 'moodle/competency:competencyview', 'nopermissions', '');
+        if (!has_any_capability(array('moodle/competency:coursecompetencyview', 'moodle/competency:coursecompetencymanage'),
+                $coursecontext)) {
+            throw new required_capability_exception($coursecontext, 'moodle/competency:coursecompetencyview', 'nopermissions', '');
         }
 
         return user_competency_course::get_least_proficient_competencies_for_course($courseid, $skip, $limit);
index 803f2ef..ec627db 100644 (file)
@@ -75,7 +75,7 @@ class progress {
         // Get the number of modules that have been completed.
         $completed = 0;
         foreach ($modules as $module) {
-            $data = $completion->get_data($module, false, $userid);
+            $data = $completion->get_data($module, true, $userid);
             $completed += $data->completionstate == COMPLETION_INCOMPLETE ? 0 : 1;
         }
 
index 40bef75..d2e7092 100644 (file)
@@ -139,13 +139,15 @@ class core_course_management_renderer extends plugin_renderer_base {
         $listing = core_course_category::get(0)->get_children();
 
         $attributes = array(
-            'class' => 'ml',
-            'role' => 'tree',
-            'aria-labelledby' => 'category-listing-title'
+                'class' => 'ml-1 list-unstyled',
+                'role' => 'tree',
+                'aria-labelledby' => 'category-listing-title'
         );
 
-        $html  = html_writer::start_div('category-listing');
-        $html .= html_writer::tag('h3', get_string('categories'), array('id' => 'category-listing-title'));
+        $html  = html_writer::start_div('category-listing card w-100');
+        $html .= html_writer::tag('h3', get_string('categories'),
+                array('class' => 'card-header', 'id' => 'category-listing-title'));
+        $html .= html_writer::start_div('card-body');
         $html .= $this->category_listing_actions($category);
         $html .= html_writer::start_tag('ul', $attributes);
         foreach ($listing as $listitem) {
@@ -155,16 +157,17 @@ class core_course_management_renderer extends plugin_renderer_base {
                 $subcategories = $listitem->get_children();
             }
             $html .= $this->category_listitem(
-                $listitem,
-                $subcategories,
-                $listitem->get_children_count(),
-                $selectedcategory,
-                $selectedparents
+                    $listitem,
+                    $subcategories,
+                    $listitem->get_children_count(),
+                    $selectedcategory,
+                    $selectedparents
             );
         }
         $html .= html_writer::end_tag('ul');
         $html .= $this->category_bulk_actions($category);
         $html .= html_writer::end_div();
+        $html .= html_writer::end_div();
         return $html;
     }
 
@@ -181,20 +184,20 @@ class core_course_management_renderer extends plugin_renderer_base {
      * @return string
      */
     public function category_listitem(core_course_category $category, array $subcategories, $totalsubcategories,
-                                      $selectedcategory = null, $selectedcategories = array()) {
+            $selectedcategory = null, $selectedcategories = array()) {
 
         $isexpandable = ($totalsubcategories > 0);
         $isexpanded = (!empty($subcategories));
         $activecategory = ($selectedcategory === $category->id);
         $attributes = array(
-            'class' => 'listitem listitem-category',
-            'data-id' => $category->id,
-            'data-expandable' => $isexpandable ? '1' : '0',
-            'data-expanded' => $isexpanded ? '1' : '0',
-            'data-selected' => $activecategory ? '1' : '0',
-            'data-visible' => $category->visible ? '1' : '0',
-            'role' => 'treeitem',
-            'aria-expanded' => $isexpanded ? 'true' : 'false'
+                'class' => 'listitem listitem-category list-group-item list-group-item-action',
+                'data-id' => $category->id,
+                'data-expandable' => $isexpandable ? '1' : '0',
+                'data-expanded' => $isexpanded ? '1' : '0',
+                'data-selected' => $activecategory ? '1' : '0',
+                'data-visible' => $category->visible ? '1' : '0',
+                'role' => 'treeitem',
+                'aria-expanded' => $isexpanded ? 'true' : 'false'
         );
         $text = $category->get_formatted_name();
         if ($category->parent) {
@@ -205,12 +208,12 @@ class core_course_management_renderer extends plugin_renderer_base {
         }
         $courseicon = $this->output->pix_icon('i/course', get_string('courses'));
         $bcatinput = array(
-            'type' => 'checkbox',
-            'name' => 'bcat[]',
-            'value' => $category->id,
-            'class' => 'bulk-action-checkbox',
-            'aria-label' => get_string('bulkactionselect', 'moodle', $text),
-            'data-action' => 'select'
+                'type' => 'checkbox',
+                'name' => 'bcat[]',
+                'value' => $category->id,
+                'class' => 'bulk-action-checkbox',
+                'aria-label' => get_string('bulkactionselect', 'moodle', $text),
+                'data-action' => 'select'
         );
 
         if (!$category->can_resort_subcategories() && !$category->has_manage_capability()) {
@@ -220,34 +223,36 @@ class core_course_management_renderer extends plugin_renderer_base {
 
         $viewcaturl = new moodle_url('/course/management.php', array('categoryid' => $category->id));
         if ($isexpanded) {
-            $icon = $this->output->pix_icon('t/switch_minus', get_string('collapse'), 'moodle', array('class' => 'tree-icon', 'title' => ''));
+            $icon = $this->output->pix_icon('t/switch_minus', get_string('collapse'),
+                    'moodle', array('class' => 'tree-icon', 'title' => ''));
             $icon = html_writer::link(
-                $viewcaturl,
-                $icon,
-                array(
-                    'class' => 'float-left',
-                    'data-action' => 'collapse',
-                    'title' => get_string('collapsecategory', 'moodle', $text),
-                    'aria-controls' => 'subcategoryof'.$category->id
-                )
+                    $viewcaturl,
+                    $icon,
+                    array(
+                            'class' => 'float-left',
+                            'data-action' => 'collapse',
+                            'title' => get_string('collapsecategory', 'moodle', $text),
+                            'aria-controls' => 'subcategoryof'.$category->id
+                    )
             );
         } else if ($isexpandable) {
-            $icon = $this->output->pix_icon('t/switch_plus', get_string('expand'), 'moodle', array('class' => 'tree-icon', 'title' => ''));
+            $icon = $this->output->pix_icon('t/switch_plus', get_string('expand'),
+                    'moodle', array('class' => 'tree-icon', 'title' => ''));
             $icon = html_writer::link(
-                $viewcaturl,
-                $icon,
-                array(
-                    'class' => 'float-left',
-                    'data-action' => 'expand',
-                    'title' => get_string('expandcategory', 'moodle', $text)
-                )
+                    $viewcaturl,
+                    $icon,
+                    array(
+                            'class' => 'float-left',
+                            'data-action' => 'expand',
+                            'title' => get_string('expandcategory', 'moodle', $text)
+                    )
             );
         } else {
             $icon = $this->output->pix_icon(
-                'i/empty',
-                '',
-                'moodle',
-                array('class' => 'tree-icon'));
+                    'i/empty',
+                    '',
+                    'moodle',
+                    array('class' => 'tree-icon'));
             $icon = html_writer::span($icon, 'float-left');
         }
         $actions = \core_course\management\helper::get_category_listitem_actions($category);
@@ -268,7 +273,7 @@ class core_course_management_renderer extends plugin_renderer_base {
             $textattributes['aria-label'] = $textlabel;
         }
         $html .= html_writer::link($viewcaturl, $text, $textattributes);
-        $html .= html_writer::start_div('float-right');
+        $html .= html_writer::start_div('float-right d-flex');
         if ($category->idnumber) {
             $html .= html_writer::tag('span', s($category->idnumber), array('class' => 'dimmed idnumber'));
         }
@@ -277,28 +282,28 @@ class core_course_management_renderer extends plugin_renderer_base {
         }
         $countid = 'course-count-'.$category->id;
         $html .= html_writer::span(
-            html_writer::span($category->get_courses_count()) .
-            html_writer::span(get_string('courses'), 'accesshide', array('id' => $countid)) .
-            $courseicon,
-            'course-count dimmed',
-            array('aria-labelledby' => $countid)
+                html_writer::span($category->get_courses_count()) .
+                html_writer::span(get_string('courses'), 'accesshide', array('id' => $countid)) .
+                $courseicon,
+                'course-count dimmed',
+                array('aria-labelledby' => $countid)
         );
         $html .= html_writer::end_div();
         $html .= html_writer::end_div();
         if ($isexpanded) {
             $html .= html_writer::start_tag('ul',
-                array('class' => 'ml', 'role' => 'group', 'id' => 'subcategoryof'.$category->id));
+                    array('class' => 'ml', 'role' => 'group', 'id' => 'subcategoryof'.$category->id));
             $catatlevel = \core_course\management\helper::get_expanded_categories($category->path);
             $catatlevel[] = array_shift($selectedcategories);
             $catatlevel = array_unique($catatlevel);
             foreach ($subcategories as $listitem) {
                 $childcategories = (in_array($listitem->id, $catatlevel)) ? $listitem->get_children() : array();
                 $html .= $this->category_listitem(
-                    $listitem,
-                    $childcategories,
-                    $listitem->get_children_count(),
-                    $selectedcategory,
-                    $selectedcategories
+                        $listitem,
+                        $childcategories,
+                        $listitem->get_children_count(),
+                        $selectedcategory,
+                        $selectedcategories
                 );
             }
             $html .= html_writer::end_tag('ul');
@@ -327,7 +332,7 @@ class core_course_management_renderer extends plugin_renderer_base {
 
         if ($cancreatecategory) {
             $url = new moodle_url('/course/editcategory.php', array('parent' => $category->id));
-            $actions[] = html_writer::link($url, get_string('createnewcategory'));
+            $actions[] = html_writer::link($url, get_string('createnewcategory'), array('class' => 'btn btn-default'));
         }
         if (core_course_category::can_approve_course_requests()) {
             $actions[] = html_writer::link(new moodle_url('/course/pending.php'), get_string('coursespending'));
@@ -335,7 +340,7 @@ class core_course_management_renderer extends plugin_renderer_base {
         if (count($actions) === 0) {
             return '';
         }
-        return html_writer::div(join(' | ', $actions), 'listing-actions category-listing-actions');
+        return html_writer::div(join(' ', $actions), 'listing-actions category-listing-actions mb-3');
     }
 
     /**
@@ -480,20 +485,19 @@ class core_course_management_renderer extends plugin_renderer_base {
      * Renders a course listing.
      *
      * @param core_course_category $category The currently selected category. This is what the listing is focused on.
-     * @param core_course_list_element  $course The currently selected course.
+     * @param core_course_list_element $course The currently selected course.
      * @param int $page The page being displayed.
      * @param int $perpage The number of courses to display per page.
      * @param string|null $viewmode The view mode the page is in, one out of 'default', 'combined', 'courses' or 'categories'.
      * @return string
      */
     public function course_listing(core_course_category $category = null, core_course_list_element $course = null,
-                                   $page = 0, $perpage = 20,
-        $viewmode = 'default') {
+            $page = 0, $perpage = 20, $viewmode = 'default') {
 
         if ($category === null) {
             $html = html_writer::start_div('select-a-category');
             $html .= html_writer::tag('h3', get_string('courses'),
-                array('id' => 'course-listing-title', 'tabindex' => '0'));
+                    array('id' => 'course-listing-title', 'tabindex' => '0'));
             $html .= $this->output->notification(get_string('selectacategory'), 'notifymessage');
             $html .= html_writer::end_div();
             return $html;
@@ -507,8 +511,8 @@ class core_course_management_renderer extends plugin_renderer_base {
             $page = $totalpages - 1;
         }
         $options = array(
-            'offset' => $page * $perpage,
-            'limit' => $perpage
+                'offset' => $page * $perpage,
+                'limit' => $perpage
         );
         $courseid = isset($course) ? $course->id : null;
         $class = '';
@@ -519,15 +523,16 @@ class core_course_management_renderer extends plugin_renderer_base {
             $class .= ' lastpage';
         }
 
-        $html  = html_writer::start_div('course-listing'.$class, array(
-            'data-category' => $category->id,
-            'data-page' => $page,
-            'data-totalpages' => $totalpages,
-            'data-totalcourses' => $totalcourses,
-            'data-canmoveoutof' => $category->can_move_courses_out_of() && $category->can_move_courses_into()
+        $html  = html_writer::start_div('card course-listing w-100'.$class, array(
+                'data-category' => $category->id,
+                'data-page' => $page,
+                'data-totalpages' => $totalpages,
+                'data-totalcourses' => $totalcourses,
+                'data-canmoveoutof' => $category->can_move_courses_out_of() && $category->can_move_courses_into()
         ));
         $html .= html_writer::tag('h3', $category->get_formatted_name(),
-            array('id' => 'course-listing-title', 'tabindex' => '0'));
+                array('id' => 'course-listing-title', 'tabindex' => '0', 'class' => 'card-header'));
+        $html .= html_writer::start_div('card-body');
         $html .= $this->course_listing_actions($category, $course, $perpage);
         $html .= $this->listing_pagination($category, $page, $perpage, false, $viewmode);
         $html .= html_writer::start_tag('ul', array('class' => 'ml course-list', 'role' => 'group'));
@@ -538,6 +543,7 @@ class core_course_management_renderer extends plugin_renderer_base {
         $html .= $this->listing_pagination($category, $page, $perpage, true, $viewmode);
         $html .= $this->course_bulk_actions($category);
         $html .= html_writer::end_div();
+        $html .= html_writer::end_div();
         return $html;
     }
 
@@ -588,7 +594,7 @@ class core_course_management_renderer extends plugin_renderer_base {
      * This function will be called for every course being displayed by course_listing.
      *
      * @param core_course_category $category The currently selected category and the category the course belongs to.
-     * @param core_course_list_element  $course The course to produce HTML for.
+     * @param core_course_list_element $course The course to produce HTML for.
      * @param int $selectedcourse The id of the currently selected course.
      * @return string
      */
@@ -596,19 +602,19 @@ class core_course_management_renderer extends plugin_renderer_base {
 
         $text = $course->get_formatted_name();
         $attributes = array(
-            'class' => 'listitem listitem-course',
-            'data-id' => $course->id,
-            'data-selected' => ($selectedcourse == $course->id) ? '1' : '0',
-            'data-visible' => $course->visible ? '1' : '0'
+                'class' => 'listitem listitem-course list-group-item list-group-item-action',
+                'data-id' => $course->id,
+                'data-selected' => ($selectedcourse == $course->id) ? '1' : '0',
+                'data-visible' => $course->visible ? '1' : '0'
         );
 
         $bulkcourseinput = array(
-            'type' => 'checkbox',
-            'name' => 'bc[]',
-            'value' => $course->id,
-            'class' => 'bulk-action-checkbox',
-            'aria-label' => get_string('bulkactionselect', 'moodle', $text),
-            'data-action' => 'select'
+                'type' => 'checkbox',
+                'name' => 'bc[]',
+                'value' => $course->id,
+                'class' => 'bulk-action-checkbox',
+                'aria-label' => get_string('bulkactionselect', 'moodle', $text),
+                'data-action' => 'select'
         );
         if (!$category->has_manage_capability()) {
             // Very very hardcoded here.
@@ -654,7 +660,7 @@ class core_course_management_renderer extends plugin_renderer_base {
         $actions = array();
         if ($category->can_create_course()) {
             $url = new moodle_url('/course/edit.php', array('category' => $category->id, 'returnto' => 'catmanage'));
-            $actions[] = html_writer::link($url, get_string('createnewcourse'));
+            $actions[] = html_writer::link($url, get_string('createnewcourse'), array('class' => 'btn btn-default'));
         }
         if ($category->can_request_course()) {
             // Request a new course.
@@ -675,42 +681,42 @@ class core_course_management_renderer extends plugin_renderer_base {
             $timecreatedurl = new moodle_url($baseurl, array('resort' => 'timecreated'));
             $timecreateddescurl = new moodle_url($baseurl, array('resort' => 'timecreateddesc'));
             $menu = new action_menu(array(
-                new action_menu_link_secondary($fullnameurl,
-                                               null,
-                                               get_string('sortbyx', 'moodle', get_string('fullnamecourse'))),
-                new action_menu_link_secondary($fullnameurldesc,
-                                               null,
-                                               get_string('sortbyxreverse', 'moodle', get_string('fullnamecourse'))),
-                new action_menu_link_secondary($shortnameurl,
-                                               null,
-                                               get_string('sortbyx', 'moodle', get_string('shortnamecourse'))),
-                new action_menu_link_secondary($shortnameurldesc,
-                                               null,
-                                               get_string('sortbyxreverse', 'moodle', get_string('shortnamecourse'))),
-                new action_menu_link_secondary($idnumberurl,
-                                               null,
-                                               get_string('sortbyx', 'moodle', get_string('idnumbercourse'))),
-                new action_menu_link_secondary($idnumberdescurl,
-                                               null,
-                                               get_string('sortbyxreverse', 'moodle', get_string('idnumbercourse'))),
-                new action_menu_link_secondary($timecreatedurl,
-                                               null,
-                                               get_string('sortbyx', 'moodle', get_string('timecreatedcourse'))),
-                new action_menu_link_secondary($timecreateddescurl,
-                                               null,
-                                               get_string('sortbyxreverse', 'moodle', get_string('timecreatedcourse')))
+                    new action_menu_link_secondary($fullnameurl,
+                            null,
+                            get_string('sortbyx', 'moodle', get_string('fullnamecourse'))),
+                    new action_menu_link_secondary($fullnameurldesc,
+                            null,
+                            get_string('sortbyxreverse', 'moodle', get_string('fullnamecourse'))),
+                    new action_menu_link_secondary($shortnameurl,
+                            null,
+                            get_string('sortbyx', 'moodle', get_string('shortnamecourse'))),
+                    new action_menu_link_secondary($shortnameurldesc,
+                            null,
+                            get_string('sortbyxreverse', 'moodle', get_string('shortnamecourse'))),
+                    new action_menu_link_secondary($idnumberurl,
+                            null,
+                            get_string('sortbyx', 'moodle', get_string('idnumbercourse'))),
+                    new action_menu_link_secondary($idnumberdescurl,
+                            null,
+                            get_string('sortbyxreverse', 'moodle', get_string('idnumbercourse'))),
+                    new action_menu_link_secondary($timecreatedurl,
+                            null,
+                            get_string('sortbyx', 'moodle', get_string('timecreatedcourse'))),
+                    new action_menu_link_secondary($timecreateddescurl,
+                            null,
+                            get_string('sortbyxreverse', 'moodle', get_string('timecreatedcourse')))
             ));
             $menu->set_menu_trigger(get_string('resortcourses'));
             $actions[] = $this->render($menu);
         }
         $strall = get_string('all');
         $menu = new action_menu(array(
-            new action_menu_link_secondary(new moodle_url($this->page->url, array('perpage' => 5)), null, 5),
-            new action_menu_link_secondary(new moodle_url($this->page->url, array('perpage' => 10)), null, 10),
-            new action_menu_link_secondary(new moodle_url($this->page->url, array('perpage' => 20)), null, 20),
-            new action_menu_link_secondary(new moodle_url($this->page->url, array('perpage' => 50)), null, 50),
-            new action_menu_link_secondary(new moodle_url($this->page->url, array('perpage' => 100)), null, 100),
-            new action_menu_link_secondary(new moodle_url($this->page->url, array('perpage' => 999)), null, $strall),
+                new action_menu_link_secondary(new moodle_url($this->page->url, array('perpage' => 5)), null, 5),
+                new action_menu_link_secondary(new moodle_url($this->page->url, array('perpage' => 10)), null, 10),
+                new action_menu_link_secondary(new moodle_url($this->page->url, array('perpage' => 20)), null, 20),
+                new action_menu_link_secondary(new moodle_url($this->page->url, array('perpage' => 50)), null, 50),
+                new action_menu_link_secondary(new moodle_url($this->page->url, array('perpage' => 100)), null, 100),
+                new action_menu_link_secondary(new moodle_url($this->page->url, array('perpage' => 999)), null, $strall),
         ));
         if ((int)$perpage === 999) {
             $perpage = $strall;
@@ -718,7 +724,7 @@ class core_course_management_renderer extends plugin_renderer_base {
         $menu->attributes['class'] .= ' courses-per-page';
         $menu->set_menu_trigger(get_string('perpagea', 'moodle', $perpage));
         $actions[] = $this->render($menu);
-        return html_writer::div(join(' ', $actions), 'listing-actions course-listing-actions');
+        return html_writer::div(join(' ', $actions), 'listing-actions course-listing-actions');
     }
 
     /**
@@ -800,20 +806,25 @@ class core_course_management_renderer extends plugin_renderer_base {
     /**
      * Renderers detailed course information.
      *
-     * @param core_course_list_element  $course The course to display details for.
+     * @param core_course_list_element $course The cours