Merge branch 'MDL-57455_master' of git://github.com/markn86/moodle
authorJake Dallimore <jake@moodle.com>
Mon, 16 Oct 2017 02:34:01 +0000 (10:34 +0800)
committerJake Dallimore <jake@moodle.com>
Mon, 16 Oct 2017 02:34:01 +0000 (10:34 +0800)
432 files changed:
.eslintignore
.stylelintignore
admin/settings/analytics.php
admin/tool/analytics/classes/output/renderer.php
admin/tool/analytics/lang/en/tool_analytics.php
admin/tool/usertours/amd/build/usertours.min.js
admin/tool/usertours/amd/src/usertours.js
admin/tool/usertours/templates/resettour.mustache [new file with mode: 0644]
analytics/classes/admin_setting_predictor.php
analytics/classes/dataset_manager.php
analytics/classes/local/analyser/base.php
analytics/classes/local/analyser/by_course.php
analytics/classes/local/analyser/sitewide.php
analytics/classes/local/target/base.php
analytics/classes/local/target/discrete.php
analytics/classes/local/target/linear.php
analytics/classes/model.php
analytics/tests/dataset_manager_test.php
analytics/tests/fixtures/test_analyser.php [new file with mode: 0644]
analytics/tests/model_test.php
analytics/tests/prediction_test.php
backup/backupfilesedit.php
backup/moodle2/restore_activity_task.class.php
backup/moodle2/restore_block_task.class.php
backup/moodle2/restore_course_task.class.php
backup/moodle2/restore_final_task.class.php
backup/moodle2/restore_stepslib.php
backup/moodle2/tests/moodle2_test.php
backup/restorefile.php
backup/util/ui/renderer.php
blocks/calendar_upcoming/block_calendar_upcoming.php
calendar/amd/build/calendar.min.js
calendar/amd/build/calendar_filter.min.js
calendar/amd/build/event_form.min.js
calendar/amd/src/calendar.js
calendar/amd/src/calendar_filter.js
calendar/amd/src/event_form.js
calendar/classes/external/event_exporter_base.php
calendar/classes/external/footer_options_exporter.php
calendar/classes/local/event/forms/create.php
calendar/classes/local/event/mappers/create_update_form_mapper.php
calendar/lib.php
calendar/templates/event_filter_key.mustache
calendar/templates/event_icon.mustache [new file with mode: 0644]
calendar/templates/footer_options.mustache
calendar/templates/month_detailed.mustache
calendar/templates/upcoming_mini.mustache [new file with mode: 0644]
calendar/tests/behat/calendar.feature
calendar/tests/lib_test.php
completion/classes/external.php
completion/tests/externallib_test.php
composer.json
composer.lock
course/classes/search/mycourse.php
course/externallib.php
course/lib.php
course/renderer.php
course/tests/courselib_test.php
course/tests/search_test.php
enrol/cohort/lib.php
enrol/cohort/tests/cohortlib_test.php
enrol/database/lib.php
enrol/database/tests/lib_test.php [new file with mode: 0644]
enrol/flatfile/lib.php
enrol/flatfile/tests/flatfile_test.php
enrol/locallib.php
enrol/lti/lib.php
enrol/lti/tests/lib_test.php
enrol/manual/lib.php
enrol/manual/tests/lib_test.php
enrol/meta/lib.php
enrol/meta/tests/plugin_test.php
enrol/paypal/lib.php
enrol/paypal/tests/paypal_test.php
enrol/self/lib.php
enrol/self/tests/self_test.php
filter/mathjaxloader/db/upgrade.php
filter/mathjaxloader/db/upgradelib.php
filter/mathjaxloader/filter.php
filter/mathjaxloader/readme_moodle.txt
filter/mathjaxloader/settings.php
filter/mathjaxloader/tests/filter_test.php [new file with mode: 0644]
filter/mathjaxloader/tests/upgradelib_test.php
filter/mathjaxloader/version.php
group/tests/behat/create_groups.feature
install/lang/lt/admin.php
install/lang/lt/install.php
lang/en/analytics.php
lang/en/calendar.php
lang/en/completion.php
lang/en/deprecated.txt
lang/en/role.php
lib/accesslib.php
lib/adodb/adodb-active-record.inc.php
lib/adodb/adodb-active-recordx.inc.php
lib/adodb/adodb-csvlib.inc.php
lib/adodb/adodb-datadict.inc.php
lib/adodb/adodb-error.inc.php
lib/adodb/adodb-errorhandler.inc.php
lib/adodb/adodb-errorpear.inc.php
lib/adodb/adodb-exceptions.inc.php
lib/adodb/adodb-iterator.inc.php
lib/adodb/adodb-lib.inc.php
lib/adodb/adodb-memcache.lib.inc.php
lib/adodb/adodb-pager.inc.php
lib/adodb/adodb-pear.inc.php
lib/adodb/adodb-perf.inc.php
lib/adodb/adodb-php4.inc.php
lib/adodb/adodb-time.inc.php
lib/adodb/adodb.inc.php
lib/adodb/datadict/datadict-access.inc.php
lib/adodb/datadict/datadict-db2.inc.php
lib/adodb/datadict/datadict-firebird.inc.php
lib/adodb/datadict/datadict-generic.inc.php
lib/adodb/datadict/datadict-ibase.inc.php
lib/adodb/datadict/datadict-informix.inc.php
lib/adodb/datadict/datadict-mssql.inc.php
lib/adodb/datadict/datadict-mssqlnative.inc.php
lib/adodb/datadict/datadict-mysql.inc.php
lib/adodb/datadict/datadict-oci8.inc.php
lib/adodb/datadict/datadict-postgres.inc.php
lib/adodb/datadict/datadict-sapdb.inc.php
lib/adodb/datadict/datadict-sqlite.inc.php
lib/adodb/datadict/datadict-sybase.inc.php
lib/adodb/drivers/adodb-access.inc.php
lib/adodb/drivers/adodb-ado.inc.php
lib/adodb/drivers/adodb-ado5.inc.php
lib/adodb/drivers/adodb-ado_access.inc.php
lib/adodb/drivers/adodb-ado_mssql.inc.php
lib/adodb/drivers/adodb-borland_ibase.inc.php
lib/adodb/drivers/adodb-csv.inc.php
lib/adodb/drivers/adodb-db2.inc.php
lib/adodb/drivers/adodb-db2oci.inc.php
lib/adodb/drivers/adodb-db2ora.inc.php
lib/adodb/drivers/adodb-fbsql.inc.php
lib/adodb/drivers/adodb-firebird.inc.php
lib/adodb/drivers/adodb-ibase.inc.php
lib/adodb/drivers/adodb-informix.inc.php
lib/adodb/drivers/adodb-informix72.inc.php
lib/adodb/drivers/adodb-ldap.inc.php
lib/adodb/drivers/adodb-mssql.inc.php
lib/adodb/drivers/adodb-mssqlnative.inc.php
lib/adodb/drivers/adodb-mssqlpo.inc.php
lib/adodb/drivers/adodb-mysql.inc.php
lib/adodb/drivers/adodb-mysqli.inc.php
lib/adodb/drivers/adodb-mysqlpo.inc.php
lib/adodb/drivers/adodb-mysqlt.inc.php
lib/adodb/drivers/adodb-netezza.inc.php
lib/adodb/drivers/adodb-oci8.inc.php
lib/adodb/drivers/adodb-oci805.inc.php
lib/adodb/drivers/adodb-oci8po.inc.php
lib/adodb/drivers/adodb-oci8quercus.inc.php
lib/adodb/drivers/adodb-odbc.inc.php
lib/adodb/drivers/adodb-odbc_db2.inc.php
lib/adodb/drivers/adodb-odbc_mssql.inc.php
lib/adodb/drivers/adodb-odbc_mssql2012.inc.php [deleted file]
lib/adodb/drivers/adodb-odbc_oracle.inc.php
lib/adodb/drivers/adodb-odbtp.inc.php
lib/adodb/drivers/adodb-odbtp_unicode.inc.php
lib/adodb/drivers/adodb-oracle.inc.php
lib/adodb/drivers/adodb-pdo.inc.php
lib/adodb/drivers/adodb-pdo_mssql.inc.php
lib/adodb/drivers/adodb-pdo_mysql.inc.php
lib/adodb/drivers/adodb-pdo_oci.inc.php
lib/adodb/drivers/adodb-pdo_pgsql.inc.php
lib/adodb/drivers/adodb-pdo_sqlite.inc.php
lib/adodb/drivers/adodb-postgres.inc.php
lib/adodb/drivers/adodb-postgres64.inc.php
lib/adodb/drivers/adodb-postgres7.inc.php
lib/adodb/drivers/adodb-postgres8.inc.php
lib/adodb/drivers/adodb-postgres9.inc.php
lib/adodb/drivers/adodb-proxy.inc.php
lib/adodb/drivers/adodb-sapdb.inc.php
lib/adodb/drivers/adodb-sqlanywhere.inc.php
lib/adodb/drivers/adodb-sqlite.inc.php
lib/adodb/drivers/adodb-sqlite3.inc.php
lib/adodb/drivers/adodb-sqlitepo.inc.php
lib/adodb/drivers/adodb-sybase.inc.php
lib/adodb/drivers/adodb-sybase_ase.inc.php
lib/adodb/drivers/adodb-text.inc.php
lib/adodb/drivers/adodb-vfp.inc.php
lib/adodb/perf/perf-db2.inc.php
lib/adodb/perf/perf-informix.inc.php
lib/adodb/perf/perf-mssql.inc.php
lib/adodb/perf/perf-mssqlnative.inc.php
lib/adodb/perf/perf-mysql.inc.php
lib/adodb/perf/perf-oci8.inc.php
lib/adodb/perf/perf-postgres.inc.php
lib/adodb/pivottable.inc.php
lib/adodb/readme_moodle.txt
lib/adodb/rsfilter.inc.php
lib/adodb/toexport.inc.php
lib/adodb/tohtml.inc.php
lib/classes/access/get_user_capability_course_helper.php [new file with mode: 0644]
lib/classes/analytics/target/course_dropout.php
lib/classes/event/course_module_completion_updated.php
lib/classes/oauth2/client.php
lib/classes/task/search_index_task.php
lib/classes/task/search_optimize_task.php
lib/completionlib.php
lib/db/access.php
lib/db/install.xml
lib/db/services.php
lib/db/upgrade.php
lib/enrollib.php
lib/filebrowser/file_info_context_course.php
lib/filebrowser/tests/file_browser_test.php
lib/filelib.php
lib/filestorage/file_storage.php
lib/form/form.js
lib/html2text/Html2Text.php
lib/jquery/jquery-3.1.0.min.js [deleted file]
lib/jquery/jquery-3.2.1.js [moved from lib/jquery/jquery-3.1.0.js with 94% similarity]
lib/jquery/jquery-3.2.1.min.js [new file with mode: 0644]
lib/jquery/plugins.php
lib/maxmind/GeoIp2/Database/Reader.php
lib/maxmind/GeoIp2/Exception/HttpException.php
lib/maxmind/GeoIp2/Exception/InvalidRequestException.php
lib/maxmind/GeoIp2/Model/AbstractModel.php
lib/maxmind/GeoIp2/Model/AnonymousIp.php
lib/maxmind/GeoIp2/Model/Asn.php [new file with mode: 0644]
lib/maxmind/GeoIp2/Model/City.php
lib/maxmind/GeoIp2/Model/ConnectionType.php
lib/maxmind/GeoIp2/Model/Country.php
lib/maxmind/GeoIp2/Model/Domain.php
lib/maxmind/GeoIp2/Model/Enterprise.php
lib/maxmind/GeoIp2/Model/Insights.php
lib/maxmind/GeoIp2/Model/Isp.php
lib/maxmind/GeoIp2/ProviderInterface.php
lib/maxmind/GeoIp2/Record/AbstractPlaceRecord.php
lib/maxmind/GeoIp2/Record/AbstractRecord.php
lib/maxmind/GeoIp2/Record/City.php
lib/maxmind/GeoIp2/Record/Continent.php
lib/maxmind/GeoIp2/Record/Country.php
lib/maxmind/GeoIp2/Record/Location.php
lib/maxmind/GeoIp2/Record/MaxMind.php
lib/maxmind/GeoIp2/Record/Postal.php
lib/maxmind/GeoIp2/Record/RepresentedCountry.php
lib/maxmind/GeoIp2/Record/Subdivision.php
lib/maxmind/GeoIp2/Record/Traits.php
lib/maxmind/GeoIp2/WebService/Client.php
lib/maxmind/MaxMind/Db/Reader.php
lib/maxmind/readme_moodle.txt [moved from lib/maxmind/README_moodle.txt with 77% similarity]
lib/mlbackend/php/classes/processor.php
lib/mlbackend/python/classes/processor.php
lib/myprofilelib.php
lib/requirejs/moodle-config.js
lib/spout/readme_moodle.txt
lib/spout/src/Spout/Common/Escaper/ODS.php
lib/spout/src/Spout/Common/Escaper/XLSX.php
lib/spout/src/Spout/Common/Helper/FileSystemHelper.php
lib/spout/src/Spout/Reader/AbstractReader.php
lib/spout/src/Spout/Reader/CSV/Reader.php
lib/spout/src/Spout/Reader/CSV/ReaderOptions.php [new file with mode: 0644]
lib/spout/src/Spout/Reader/CSV/RowIterator.php
lib/spout/src/Spout/Reader/CSV/Sheet.php
lib/spout/src/Spout/Reader/CSV/SheetIterator.php
lib/spout/src/Spout/Reader/Common/ReaderOptions.php [new file with mode: 0644]
lib/spout/src/Spout/Reader/Common/XMLProcessor.php [new file with mode: 0644]
lib/spout/src/Spout/Reader/ODS/Helper/CellValueFormatter.php
lib/spout/src/Spout/Reader/ODS/Helper/SettingsHelper.php [new file with mode: 0644]
lib/spout/src/Spout/Reader/ODS/Reader.php
lib/spout/src/Spout/Reader/ODS/ReaderOptions.php [new file with mode: 0644]
lib/spout/src/Spout/Reader/ODS/RowIterator.php
lib/spout/src/Spout/Reader/ODS/Sheet.php
lib/spout/src/Spout/Reader/ODS/SheetIterator.php
lib/spout/src/Spout/Reader/Wrapper/SimpleXMLElement.php [deleted file]
lib/spout/src/Spout/Reader/Wrapper/XMLReader.php
lib/spout/src/Spout/Reader/XLSX/Helper/CellHelper.php
lib/spout/src/Spout/Reader/XLSX/Helper/CellValueFormatter.php
lib/spout/src/Spout/Reader/XLSX/Helper/DateFormatHelper.php
lib/spout/src/Spout/Reader/XLSX/Helper/SharedStringsHelper.php
lib/spout/src/Spout/Reader/XLSX/Helper/SheetHelper.php
lib/spout/src/Spout/Reader/XLSX/Helper/StyleHelper.php
lib/spout/src/Spout/Reader/XLSX/Reader.php
lib/spout/src/Spout/Reader/XLSX/ReaderOptions.php [new file with mode: 0644]
lib/spout/src/Spout/Reader/XLSX/RowIterator.php
lib/spout/src/Spout/Reader/XLSX/Sheet.php
lib/spout/src/Spout/Reader/XLSX/SheetIterator.php
lib/spout/src/Spout/Writer/AbstractWriter.php
lib/spout/src/Spout/Writer/Common/Helper/AbstractStyleHelper.php
lib/spout/src/Spout/Writer/Common/Helper/CellHelper.php
lib/spout/src/Spout/Writer/Common/Internal/AbstractWorkbook.php
lib/spout/src/Spout/Writer/Common/Sheet.php
lib/spout/src/Spout/Writer/ODS/Helper/FileSystemHelper.php
lib/spout/src/Spout/Writer/ODS/Helper/StyleHelper.php
lib/spout/src/Spout/Writer/ODS/Internal/Workbook.php
lib/spout/src/Spout/Writer/ODS/Internal/Worksheet.php
lib/spout/src/Spout/Writer/ODS/Writer.php
lib/spout/src/Spout/Writer/Style/Style.php
lib/spout/src/Spout/Writer/Style/StyleBuilder.php
lib/spout/src/Spout/Writer/WriterInterface.php
lib/spout/src/Spout/Writer/XLSX/Helper/FileSystemHelper.php
lib/spout/src/Spout/Writer/XLSX/Internal/Workbook.php
lib/spout/src/Spout/Writer/XLSX/Internal/Worksheet.php
lib/tests/accesslib_test.php
lib/tests/completionlib_test.php
lib/thirdpartylibs.xml
lib/upgrade.txt
lib/webdavlib.php
media/player/videojs/amd/build/Youtube-lazy.min.js
media/player/videojs/amd/build/loader.min.js
media/player/videojs/amd/build/video-lazy.min.js
media/player/videojs/amd/build/videojs-flash-lazy.min.js [new file with mode: 0644]
media/player/videojs/amd/src/Youtube-lazy.js
media/player/videojs/amd/src/loader.js
media/player/videojs/amd/src/video-lazy.js
media/player/videojs/amd/src/videojs-flash-lazy.js [new file with mode: 0644]
media/player/videojs/classes/plugin.php
media/player/videojs/readme_moodle.txt
media/player/videojs/styles.css
media/player/videojs/thirdpartylibs.xml
media/player/videojs/videojs/lang/ar.js
media/player/videojs/videojs/lang/de.js
media/player/videojs/videojs/lang/en.js
media/player/videojs/videojs/lang/es.js
media/player/videojs/videojs/lang/fr.js
media/player/videojs/videojs/lang/gl.js [new file with mode: 0644]
media/player/videojs/videojs/lang/nl.js
media/player/videojs/videojs/lang/pt-PT.js [new file with mode: 0644]
media/player/videojs/videojs/lang/sk.js [new file with mode: 0644]
media/player/videojs/videojs/lang/tr.js
media/player/videojs/videojs/lang/vi.js
media/player/videojs/videojs/lang/zh-CN.js
media/player/videojs/videojs/video-js.swf
message/classes/search/base_message.php
message/classes/search/message_received.php
message/classes/search/message_sent.php
message/tests/search_received_test.php [moved from message/tests/search_test_received.php with 77% similarity]
message/tests/search_sent_test.php [moved from message/tests/search_test_sent.php with 75% similarity]
mod/assign/amd/build/grading_actions.min.js
mod/assign/amd/build/grading_navigation.min.js
mod/assign/amd/build/grading_panel.min.js
mod/assign/amd/src/grading_actions.js
mod/assign/amd/src/grading_navigation.js
mod/assign/amd/src/grading_panel.js
mod/assign/feedback/editpdf/fpdi/LICENSE
mod/assign/feedback/editpdf/fpdi/filters/FilterASCII85.php
mod/assign/feedback/editpdf/fpdi/filters/FilterASCIIHexDecode.php
mod/assign/feedback/editpdf/fpdi/filters/FilterLZW.php
mod/assign/feedback/editpdf/fpdi/fpdf_tpl.php
mod/assign/feedback/editpdf/fpdi/fpdi.php
mod/assign/feedback/editpdf/fpdi/fpdi_bridge.php
mod/assign/feedback/editpdf/fpdi/fpdi_pdf_parser.php
mod/assign/feedback/editpdf/fpdi/pdf_context.php
mod/assign/feedback/editpdf/fpdi/pdf_parser.php
mod/assign/feedback/editpdf/fpdi/readme_moodle.txt
mod/assign/feedback/editpdf/thirdpartylibs.xml
mod/assign/templates/grading_actions.mustache
mod/book/classes/search/chapter.php
mod/book/lib.php
mod/book/tests/behat/reorganize_chapters.feature [new file with mode: 0644]
mod/book/tests/search_test.php
mod/data/classes/search/entry.php
mod/data/tests/search_test.php
mod/forum/classes/search/post.php
mod/forum/db/install.xml
mod/forum/db/upgrade.php
mod/forum/lib.php
mod/forum/tests/search_test.php
mod/forum/version.php
mod/glossary/classes/search/entry.php
mod/glossary/lib.php
mod/glossary/tests/search_test.php
mod/lti/locallib.php
mod/quiz/attemptlib.php
mod/quiz/report/overview/report.php
mod/quiz/report/overview/tests/report_test.php
mod/quiz/report/responses/report.php
mod/survey/classes/search/activity.php
mod/survey/tests/search_test.php [new file with mode: 0644]
mod/wiki/classes/search/collaborative_page.php
mod/wiki/tests/search_test.php
mod/workshop/classes/external.php
mod/workshop/tests/external_test.php
pix/i/completion-auto-n-override.png [new file with mode: 0644]
pix/i/completion-auto-n-override.svg [new file with mode: 0644]
pix/i/completion-auto-y-override.png [new file with mode: 0644]
pix/i/completion-auto-y-override.svg [new file with mode: 0644]
pix/i/completion-manual-n-override.png [new file with mode: 0644]
pix/i/completion-manual-n-override.svg [new file with mode: 0644]
pix/i/completion-manual-y-override.png [new file with mode: 0644]
pix/i/completion-manual-y-override.svg [new file with mode: 0644]
report/insights/classes/output/insight.php
report/insights/classes/output/insights_list.php
report/insights/lang/en/report_insights.php
report/insights/templates/insight.mustache
report/insights/templates/insight_details.mustache
report/insights/templates/insights_list.mustache
report/progress/amd/build/completion_override.min.js [new file with mode: 0644]
report/progress/amd/src/completion_override.js [new file with mode: 0644]
report/progress/index.php
report/progress/tests/behat/activity_completion_report.feature [new file with mode: 0644]
search/classes/base.php
search/classes/base_activity.php
search/classes/base_block.php
search/classes/base_mod.php
search/classes/engine.php
search/classes/manager.php
search/classes/skip_future_documents_iterator.php [new file with mode: 0644]
search/cli/indexer.php
search/engine/solr/classes/engine.php
search/tests/base_activity_test.php
search/tests/base_block_test.php
search/tests/manager_test.php
search/upgrade.txt
theme/boost/lang/en/theme_boost.php
theme/boost/lib.php
theme/boost/scss/moodle/calendar.scss
theme/boost/settings.php
theme/boost/templates/columns2.mustache
theme/boost/templates/mod_assign/grading_actions.mustache
theme/bootstrapbase/layout/columns2.php
theme/bootstrapbase/less/moodle/calendar.less
theme/bootstrapbase/style/moodle.css
theme/bootstrapbase/templates/report_insights/insight.mustache [deleted file]
theme/bootstrapbase/templates/report_insights/insight_details.mustache [deleted file]
theme/bootstrapbase/templates/report_insights/insights_list.mustache [deleted file]
theme/upgrade.txt
user/amd/build/unified_filter.min.js
user/amd/src/unified_filter.js
user/classes/search/user.php
user/lib.php
user/profile/field/checkbox/field.class.php
user/profile/field/menu/field.class.php
user/profile/field/upgrade.txt [new file with mode: 0644]
user/profile/lib.php
user/templates/unified_filter.mustache
user/tests/behat/filter_participants.feature
user/tests/search_test.php
version.php
webservice/upload.php

index 0622612..7d19e9c 100644 (file)
@@ -62,6 +62,8 @@ lib/validateurlsyntax.php
 media/player/videojs/amd/src/video-lazy.js
 media/player/videojs/amd/src/Youtube-lazy.js
 media/player/videojs/videojs/
+media/player/videojs/amd/src/videojs-flash-lazy.js
+media/player/videojs/videojs/video-js.swf
 mod/assign/feedback/editpdf/fpdi/
 repository/s3/S3.php
 theme/boost/scss/bootstrap/
index cfcf702..c35c560 100644 (file)
@@ -63,6 +63,8 @@ lib/validateurlsyntax.php
 media/player/videojs/amd/src/video-lazy.js
 media/player/videojs/amd/src/Youtube-lazy.js
 media/player/videojs/videojs/
+media/player/videojs/amd/src/videojs-flash-lazy.js
+media/player/videojs/videojs/video-js.swf
 mod/assign/feedback/editpdf/fpdi/
 repository/s3/S3.php
 theme/boost/scss/bootstrap/
index e8247c8..b42a252 100644 (file)
@@ -101,5 +101,10 @@ if ($hassiteconfig) {
         // Disable web interface evaluation and get predictions.
         $settings->add(new admin_setting_configcheckbox('analytics/onlycli', new lang_string('onlycli', 'analytics'),
             new lang_string('onlycliinfo', 'analytics'), 1));
+
+        // Training and prediction time limit per model.
+        $settings->add(new admin_setting_configduration('analytics/modeltimelimit', new lang_string('modeltimelimit', 'analytics'),
+            new lang_string('modeltimelimitinfo', 'analytics'), 20 * MINSECS));
+
     }
 }
index 32bdcfc..628b097 100644 (file)
@@ -159,11 +159,12 @@ class renderer extends plugin_renderer_base {
             if ($trainresults->status == 0) {
                 $output .= $OUTPUT->notification(get_string('trainingprocessfinished', 'tool_analytics'),
                     \core\output\notification::NOTIFY_SUCCESS);
-            } else if ($trainresults->status === \core_analytics\model::NO_DATASET) {
+            } else if ($trainresults->status === \core_analytics\model::NO_DATASET ||
+                    $trainresults->status === \core_analytics\model::NOT_ENOUGH_DATA) {
                 $output .= $OUTPUT->notification(get_string('nodatatotrain', 'tool_analytics'),
                     \core\output\notification::NOTIFY_WARNING);
             } else {
-                $output .= $OUTPUT->notification(get_string('generalerror', 'analytics', $trainresults->status),
+                $output .= $OUTPUT->notification(get_string('generalerror', 'tool_analytics', $trainresults->status),
                     \core\output\notification::NOTIFY_ERROR);
             }
         }
@@ -183,11 +184,12 @@ class renderer extends plugin_renderer_base {
             if ($predictresults->status == 0) {
                 $output .= $OUTPUT->notification(get_string('predictionprocessfinished', 'tool_analytics'),
                     \core\output\notification::NOTIFY_SUCCESS);
-            } else if ($predictresults->status === \core_analytics\model::NO_DATASET) {
+            } else if ($predictresults->status === \core_analytics\model::NO_DATASET ||
+                    $predictresults->status === \core_analytics\model::NOT_ENOUGH_DATA) {
                 $output .= $OUTPUT->notification(get_string('nodatatopredict', 'tool_analytics'),
                     \core\output\notification::NOTIFY_WARNING);
             } else {
-                $output .= $OUTPUT->notification(get_string('generalerror', 'analytics', $predictresults->status),
+                $output .= $OUTPUT->notification(get_string('generalerror', 'tool_analytics', $predictresults->status),
                     \core\output\notification::NOTIFY_ERROR);
             }
         }
index df38a14..3cdb723 100644 (file)
@@ -30,7 +30,7 @@ $string['bettercli'] = 'Evaluating models and generating predictions may involve
 $string['cantguessstartdate'] = 'Can\'t guess the start date';
 $string['cantguessenddate'] = 'Can\'t guess the end date';
 $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 cron task iterates through enabled models and gets predictions. Models evaluation via command line is disabled. You can allow these processes to be executed manually via web interface by enabling <a href="{$a}">\'onlycli\' analytics setting</a>';
+$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['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.';
 $string['enabled'] = 'Enabled';
index 417008a..efc5448 100644 (file)
Binary files a/admin/tool/usertours/amd/build/usertours.min.js and b/admin/tool/usertours/amd/build/usertours.min.js differ
index 4541f23..044180b 100644 (file)
@@ -78,18 +78,22 @@ function(ajax, BootstrapTour, $, templates, str, log, notification) {
          * @method  addResetLink
          */
         addResetLink: function() {
-            str.get_string('resettouronpage', 'tool_usertours')
-                .done(function(s) {
-                    // Grab the last item in the page of these.
-                    $('footer, .logininfo')
-                    .last()
-                    .append(
-                        '<div class="usertour">' +
-                            '<a href="#" data-action="tool_usertours/resetpagetour">' +
-                                s +
-                            '</a>' +
-                        '</div>'
-                    );
+            var ele;
+            // Append the link to the most suitable place on the page
+            // with fallback to legacy selectors and finally the body
+            // if there is no better place.
+            if ($('.tool_usertours-resettourcontainer').length) {
+                ele = $('.tool_usertours-resettourcontainer');
+            } else if ($('.logininfo').length) {
+                ele = $('.logininfo');
+            } else if ($('footer').length) {
+                ele = $('footer');
+            } else {
+                ele = $('body');
+            }
+            templates.render('tool_usertours/resettour', {})
+                .done(function(html, js) {
+                    templates.appendNodeContents(ele, html, js);
                 });
         },
 
diff --git a/admin/tool/usertours/templates/resettour.mustache b/admin/tool/usertours/templates/resettour.mustache
new file mode 100644 (file)
index 0000000..b1fe728
--- /dev/null
@@ -0,0 +1,27 @@
+{{!
+    This file is part of Moodle - http://moodle.org/
+
+    Moodle is free software: you can redistribute it and/or modify
+    it under the terms of the GNU General Public License as published by
+    the Free Software Foundation, either version 3 of the License, or
+    (at your option) any later version.
+
+    Moodle is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+    GNU General Public License for more details.
+
+    You should have received a copy of the GNU General Public License
+    along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+}}
+{{!
+    @template tool_usertours/resettour
+
+    Adds the Reset user tour on this page link.
+
+    Example context (json):
+    {}
+}}
+<div class="usertour">
+    <a href="#" data-action="tool_usertours/resetpagetour">{{#str}}resettouronpage, tool_usertours{{/str}}</a>
+</div>
index 203ad55..c523697 100644 (file)
@@ -58,6 +58,15 @@ class admin_setting_predictor extends \admin_setting_configselect {
             return get_string('errorprocessornotready', 'analytics', $isready);
         }
 
+        $currentvalue = get_config('analytics', 'predictionsprocessor');
+        if (!empty($currentvalue) && $currentvalue != str_replace('\\\\', '\\', $data)) {
+            // Clear all models data.
+            $models = \core_analytics\manager::get_all_models();
+            foreach ($models as $model) {
+                $model->clear();
+            }
+        }
+
         return ($this->config_write($this->name, $data) ? '' : get_string('errorsetting', 'admin'));
     }
 }
index 4b457e8..b467168 100644 (file)
@@ -202,6 +202,61 @@ class dataset_manager {
             '/timesplitting/' . self::clean_time_splitting_id($timesplittingid) . '/', self::EVALUATION_FILENAME);
     }
 
+    /**
+     * Gets the list of files that couldn't be previously used for training and prediction.
+     *
+     * @param int $modelid
+     * @param bool $includetarget
+     * @param string[] $timesplittingids
+     * @return null
+     */
+    public static function get_pending_files($modelid, $includetarget, $timesplittingids) {
+        global $DB;
+
+        $fs = get_file_storage();
+
+        if ($includetarget) {
+            $filearea = self::LABELLED_FILEAREA;
+            $usedfileaction = 'trained';
+        } else {
+            $filearea = self::UNLABELLED_FILEAREA;
+            $usedfileaction = 'predicted';
+        }
+
+        $select = 'modelid = :modelid AND action = :action';
+        $params = array('modelid' => $modelid, 'action' => $usedfileaction);
+        $usedfileids = $DB->get_fieldset_select('analytics_used_files', 'fileid', $select, $params);
+
+        // Very likely that we will only have 1 time splitting method here.
+        $filesbytimesplitting = array();
+        foreach ($timesplittingids as $timesplittingid) {
+
+            $filepath = '/timesplitting/' . self::clean_time_splitting_id($timesplittingid) . '/';
+            $files = $fs->get_directory_files(\context_system::instance()->id, 'analytics', $filearea, $modelid, $filepath);
+            foreach ($files as $file) {
+
+                // Discard evaluation files.
+                if ($file->get_filename() === self::EVALUATION_FILENAME) {
+                    continue;
+                }
+
+                // No dirs.
+                if ($file->is_directory()) {
+                    continue;
+                }
+
+                // Already used for training.
+                if (in_array($file->get_id(), $usedfileids)) {
+                    continue;
+                }
+
+                $filesbytimesplitting[$timesplittingid][] = $file;
+            }
+        }
+
+        return $filesbytimesplitting;
+    }
+
     /**
      * Deletes previous evaluation files of this model.
      *
index 63c75b5..8d792b4 100644 (file)
@@ -118,7 +118,7 @@ abstract class base {
      * \core_analytics\local\analyser\by_course and \core_analytics\local\analyser\sitewide are implementing
      * this method returning site courses (by_course) and the whole system (sitewide) as analysables.
      *
-     * @return \core_analytics\analysable[]
+     * @return \core_analytics\analysable[] Array of analysable elements using the analysable id as array key.
      */
     abstract public function get_analysables();
 
@@ -180,23 +180,63 @@ abstract class base {
      * @return \stored_file[]
      */
     public function get_analysable_data($includetarget) {
+        global $DB;
+
+        // Time limit control.
+        $modeltimelimit = intval(get_config('analytics', 'modeltimelimit'));
 
         $filesbytimesplitting = array();
 
-        $analysables = $this->get_analysables();
-        foreach ($analysables as $analysable) {
+        list($analysables, $processedanalysables) = $this->get_sorted_analysables($includetarget);
+
+        $inittime = time();
+        foreach ($analysables as $key => $analysable) {
 
             $files = $this->process_analysable($analysable, $includetarget);
 
             // Later we will need to aggregate data by time splitting method.
             foreach ($files as $timesplittingid => $file) {
-                $filesbytimesplitting[$timesplittingid][$analysable->get_id()] = $file;
+                $filesbytimesplitting[$timesplittingid][] = $file;
+            }
+
+            $this->update_analysable_analysed_time($processedanalysables, $analysable->get_id(), $includetarget);
+
+            // Apply time limit.
+            if (!$this->options['evaluation']) {
+                $timespent = time() - $inittime;
+                if ($modeltimelimit <= $timespent) {
+                    break;
+                }
+            }
+
+            unset($analysables[$key]);
+        }
+
+        if ($this->options['evaluation'] === false) {
+            // Look for previous training and prediction files we generated and couldn't be used
+            // by machine learning backends because they weren't big enough.
+
+            $pendingfiles = \core_analytics\dataset_manager::get_pending_files($this->modelid, $includetarget,
+                array_keys($filesbytimesplitting));
+            foreach ($pendingfiles as $timesplittingid => $files) {
+                foreach ($files as $file) {
+                    $filesbytimesplitting[$timesplittingid][] = $file;
+                }
             }
         }
 
         // We join the datasets by time splitting method.
         $timesplittingfiles = $this->merge_analysable_files($filesbytimesplitting, $includetarget);
 
+        if (!empty($pendingfiles)) {
+            // We must remove them now as they are already part of another dataset.
+            foreach ($pendingfiles as $timesplittingid => $files) {
+                foreach ($files as $file) {
+                    $file->delete();
+                }
+            }
+        }
+
         return $timesplittingfiles;
     }
 
@@ -721,4 +761,86 @@ abstract class base {
             $data[1][] = $value;
         }
     }
+
+    /**
+     * Returns the list of analysables sorted in processing priority order.
+     *
+     * It will first return analysables that have never been analysed before
+     * and it will continue with the ones we have already seen by timeanalysed DESC
+     * order.
+     *
+     * @param bool $includetarget
+     * @return array(0 => \core_analytics\analysable[], 1 => \stdClass[])
+     */
+    protected function get_sorted_analysables($includetarget) {
+
+        $analysables = $this->get_analysables();
+
+        // Get the list of analysables that have been already processed.
+        $processedanalysables = $this->get_processed_analysables($includetarget);
+
+        // We want to start processing analysables we have not yet processed and later continue
+        // with analysables that we already processed.
+        $unseen = array_diff_key($analysables, $processedanalysables);
+
+        // Var $processed first as we want to respect its timeanalysed DESC order so analysables that
+        // have recently been processed are on the bottom of the stack.
+        $seen = array_intersect_key($processedanalysables, $analysables);
+        array_walk($seen, function(&$value, $analysableid) use ($analysables) {
+            // We replace the analytics_used_analysables record by the analysable object.
+            $value = $analysables[$analysableid];
+        });
+
+        return array($unseen + $seen, $processedanalysables);
+    }
+
+    /**
+     * Get analysables that have been already processed.
+     *
+     * @param bool $includetarget
+     * @return \stdClass[]
+     */
+    protected function get_processed_analysables($includetarget) {
+        global $DB;
+
+        $params = array('modelid' => $this->modelid);
+        $params['action'] = ($includetarget) ? 'training' : 'prediction';
+        $select = 'modelid = :modelid and action = :action';
+
+        // Weird select fields ordering for performance (analysableid key matching, analysableid is also unique by modelid).
+        return $DB->get_records_select('analytics_used_analysables', $select,
+            $params, 'timeanalysed DESC', 'analysableid, modelid, action, timeanalysed, id AS primarykey');
+    }
+
+    /**
+     * Updates the analysable analysis time.
+     *
+     * @param array $processedanalysables
+     * @param int $analysableid
+     * @param bool $includetarget
+     * @return null
+     */
+    protected function update_analysable_analysed_time($processedanalysables, $analysableid, $includetarget) {
+        global $DB;
+
+        if (!empty($processedanalysables[$analysableid])) {
+            $obj = $processedanalysables[$analysableid];
+
+            $obj->id = $obj->primarykey;
+            unset($obj->primarykey);
+
+            $obj->timeanalysed = time();
+            $DB->update_record('analytics_used_analysables', $obj);
+
+        } else {
+
+            $obj = new \stdClass();
+            $obj->modelid = $this->modelid;
+            $obj->action = ($includetarget) ? 'training' : 'prediction';
+            $obj->analysableid = $analysableid;
+            $obj->timeanalysed = time();
+
+            $DB->insert_record('analytics_used_analysables', $obj);
+        }
+    }
 }
index 8e5b266..6661806 100644 (file)
@@ -55,7 +55,7 @@ abstract class by_course extends base {
         foreach ($courses as $course) {
             // Skip the frontpage course.
             $analysable = \core_analytics\course::instance($course);
-            $analysables[] = $analysable;
+            $analysables[$analysable->get_id()] = $analysable;
         }
 
         if (empty($analysables)) {
index 2a21359..075b930 100644 (file)
@@ -42,6 +42,6 @@ abstract class sitewide extends base {
      */
     public function get_analysables() {
         $analysable = new \core_analytics\site();
-        return array($analysable);
+        return array(SYSCONTEXTID => $analysable);
     }
 }
index 5b741d2..3e84120 100644 (file)
@@ -261,7 +261,7 @@ abstract class base extends \core_analytics\calculable {
     }
 
     /**
-     * Should the model callback be triggered?
+     * This method determines if a prediction is interesing for the model or not.
      *
      * @param mixed $predictedvalue
      * @param float $predictionscore
index cbd8fe0..26044c7 100644 (file)
@@ -152,7 +152,10 @@ abstract class discrete extends base {
     }
 
     /**
-     * Should the model callback be triggered?
+     * This method determines if a prediction is interesing for the model or not.
+     *
+     * This method internally calls ignored_predicted_classes to skip classes
+     * flagged by the target as not important for users.
      *
      * @param mixed $predictedvalue
      * @param float $predictionscore
index d16ad96..f10d843 100644 (file)
@@ -84,7 +84,7 @@ abstract class linear extends base {
     }
 
     /**
-     * Should the model callback be triggered?
+     * This method determines if a prediction is interesing for the model or not.
      *
      * @param mixed $predictedvalue
      * @param float $predictionscore
index 4bf9ea1..f04eb96 100644 (file)
@@ -53,12 +53,12 @@ class model {
     /**
      * Model with low prediction accuracy.
      */
-    const EVALUATE_LOW_SCORE = 4;
+    const LOW_SCORE = 4;
 
     /**
      * Not enough data to evaluate the model properly.
      */
-    const EVALUATE_NOT_ENOUGH_DATA = 8;
+    const NOT_ENOUGH_DATA = 8;
 
     /**
      * Invalid analysable for the time splitting method.
@@ -437,7 +437,7 @@ class model {
                 $this->model->indicators !== $indicatorsstr) {
 
             // Delete generated predictions before changing the model version.
-            $this->clear_model();
+            $this->clear();
 
             // It needs to be reset as the version changes.
             $this->uniqueid = null;
@@ -474,9 +474,9 @@ class model {
 
         \core_analytics\manager::check_can_manage_models();
 
-        $this->clear_model();
+        $this->clear();
 
-        // Method self::clear_model is already clearing the current model version.
+        // Method self::clear is already clearing the current model version.
         $predictor = \core_analytics\manager::get_predictions_processor();
         $predictor->delete_output_dir($this->get_output_dir(array(), true));
 
@@ -633,6 +633,10 @@ class model {
         $result->status = $predictorresult->status;
         $result->info = $predictorresult->info;
 
+        if ($result->status !== self::OK) {
+            return $result;
+        }
+
         $this->flag_file_as_used($samplesfile, 'trained');
 
         // Mark the model as trained if it wasn't.
@@ -689,7 +693,7 @@ class model {
         $samplesfile = $samplesdata[$this->model->timesplitting];
 
         // We need to throw an exception if we are trying to predict stuff that was already predicted.
-        $params = array('modelid' => $this->model->id, 'fileid' => $samplesfile->get_id(), 'action' => 'predicted');
+        $params = array('modelid' => $this->model->id, 'action' => 'predicted', 'fileid' => $samplesfile->get_id());
         if ($predicted = $DB->get_record('analytics_used_files', $params)) {
             throw new \moodle_exception('erroralreadypredict', 'analytics', '', $samplesfile->get_id());
         }
@@ -717,6 +721,10 @@ class model {
             $result->predictions = $this->format_predictor_predictions($predictorresult);
         }
 
+        if ($result->status !== self::OK) {
+            return $result;
+        }
+
         if ($result->predictions) {
             $samplecontexts = $this->execute_prediction_callbacks($result->predictions, $indicatorcalculations);
         }
@@ -780,15 +788,16 @@ class model {
 
         // Here we will store all predictions' contexts, this will be used to limit which users will see those predictions.
         $samplecontexts = array();
+        $records = array();
 
         foreach ($predictions as $uniquesampleid => $prediction) {
 
-            if ($this->get_target()->triggers_callback($prediction->prediction, $prediction->predictionscore)) {
+            // The unique sample id contains both the sampleid and the rangeindex.
+            list($sampleid, $rangeindex) = $this->get_time_splitting()->infer_sample_info($uniquesampleid);
 
-                // The unique sample id contains both the sampleid and the rangeindex.
-                list($sampleid, $rangeindex) = $this->get_time_splitting()->infer_sample_info($uniquesampleid);
+            if ($this->get_target()->triggers_callback($prediction->prediction, $prediction->predictionscore)) {
 
-                // Store the predicted values.
+                // Prepare the record to store the predicted values.
                 list($record, $samplecontext) = $this->prepare_prediction_record($sampleid, $rangeindex, $prediction->prediction,
                     $prediction->predictionscore, json_encode($indicatorcalculations[$uniquesampleid]));
 
@@ -932,8 +941,6 @@ class model {
      * @return \context
      */
     protected function prepare_prediction_record($sampleid, $rangeindex, $prediction, $predictionscore, $calculations) {
-        global $DB;
-
         $context = $this->get_analyser()->sample_access_context($sampleid);
 
         $record = new \stdClass();
@@ -946,6 +953,15 @@ class model {
         $record->calculations = $calculations;
         $record->timecreated = time();
 
+        $analysable = $this->get_analyser()->get_sample_analysable($sampleid);
+        $timesplitting = $this->get_time_splitting();
+        $timesplitting->set_analysable($analysable);
+        $range = $timesplitting->get_range_by_index($rangeindex);
+        if ($range) {
+            $record->timestart = $range['start'];
+            $record->timeend = $range['end'];
+        }
+
         return array($record, $context);
     }
 
@@ -983,7 +999,7 @@ class model {
             }
 
             // Delete generated predictions before changing the model version.
-            $this->clear_model();
+            $this->clear();
 
             // It needs to be reset as the version changes.
             $this->uniqueid = null;
@@ -1261,7 +1277,7 @@ class model {
             $outputdir = rtrim($CFG->dataroot, '/') . DIRECTORY_SEPARATOR . 'models';
         }
 
-        // Append model id
+        // Append model id.
         $outputdir .= DIRECTORY_SEPARATOR . $this->model->id;
         if (!$onlymodelid) {
             // Append version + subdirs.
@@ -1428,9 +1444,11 @@ class model {
      *
      * @return void
      */
-    private function clear_model() {
+    public function clear() {
         global $DB;
 
+        \core_analytics\manager::check_can_manage_models();
+
         // Delete current model version stored stuff.
         $predictor = \core_analytics\manager::get_predictions_processor();
         $predictor->clear_model($this->get_unique_id(), $this->get_output_dir());
@@ -1446,6 +1464,7 @@ class model {
         $DB->delete_records('analytics_predict_samples', array('modelid' => $this->model->id));
         $DB->delete_records('analytics_train_samples', array('modelid' => $this->model->id));
         $DB->delete_records('analytics_used_files', array('modelid' => $this->model->id));
+        $DB->delete_records('analytics_used_analysables', array('modelid' => $this->model->id));
 
         // Purge all generated files.
         \core_analytics\dataset_manager::clear_model_files($this->model->id);
index d36baf5..3856c5f 100644 (file)
@@ -34,22 +34,30 @@ defined('MOODLE_INTERNAL') || die();
 class dataset_manager_testcase extends advanced_testcase {
 
     /**
-     * test_create_dataset
+     * setUp
      *
-     * @return
+     * @return null
      */
-    public function test_create_dataset() {
+    public function setUp() {
         $this->resetAfterTest(true);
 
-        $sharedtoprows = array(
+        $this->sharedtoprows = array(
             array('var1', 'var2'),
             array('value1', 'value2'),
             array('header1', 'header2')
         );
+    }
+
+    /**
+     * test_create_dataset
+     *
+     * @return null
+     */
+    public function test_create_dataset() {
 
         $dataset1 = new \core_analytics\dataset_manager(1, 1, 'whatever', \core_analytics\dataset_manager::LABELLED_FILEAREA, false);
         $dataset1->init_process();
-        $dataset1data = array_merge($sharedtoprows, array(array('yeah', 'yeah', 'yeah')));
+        $dataset1data = array_merge($this->sharedtoprows, array(array('yeah', 'yeah', 'yeah')));
         $f1 = $dataset1->store($dataset1data);
         $dataset1->close_process();
 
@@ -63,26 +71,19 @@ class dataset_manager_testcase extends advanced_testcase {
     /**
      * test_merge_datasets
      *
-     * @return
+     * @return null
      */
     public function test_merge_datasets() {
-        $this->resetAfterTest(true);
-
-        $sharedtoprows = array(
-            array('var1', 'var2'),
-            array('value1', 'value2'),
-            array('header1', 'header2')
-        );
 
         $dataset1 = new \core_analytics\dataset_manager(1, 1, 'whatever', \core_analytics\dataset_manager::LABELLED_FILEAREA, false);
         $dataset1->init_process();
-        $dataset1data = array_merge($sharedtoprows, array(array('yeah', 'yeah', 'yeah')));
+        $dataset1data = array_merge($this->sharedtoprows, array(array('yeah', 'yeah', 'yeah')));
         $f1 = $dataset1->store($dataset1data);
         $dataset1->close_process();
 
         $dataset2 = new \core_analytics\dataset_manager(1, 2, 'whatever', \core_analytics\dataset_manager::LABELLED_FILEAREA, false);
         $dataset2->init_process();
-        $dataset2data = array_merge($sharedtoprows, array(array('no', 'no', 'no')));
+        $dataset2data = array_merge($this->sharedtoprows, array(array('no', 'no', 'no')));
         $f2 = $dataset2->store($dataset2data);
         $dataset2->close_process();
 
@@ -97,4 +98,70 @@ class dataset_manager_testcase extends advanced_testcase {
         $this->assertContains('value1', $mergedfilecontents);
         $this->assertContains('header1', $mergedfilecontents);
     }
+
+    /**
+     * test_get_pending_files
+     *
+     * @return null
+     */
+    public function test_get_pending_files() {
+        global $DB;
+
+        $this->resetAfterTest();
+
+        $fakemodelid = 123;
+        $timesplittingids = array(
+            '\core\analytics\time_splitting\quarters',
+            '\core\analytics\time_splitting\quarters_accum',
+        );
+
+        // No files.
+        $this->assertEmpty(\core_analytics\dataset_manager::get_pending_files($fakemodelid, true, $timesplittingids));
+        $this->assertEmpty(\core_analytics\dataset_manager::get_pending_files($fakemodelid, false, $timesplittingids));
+
+        // We will reuse this analysable file to create training and prediction datasets (analysable level files are
+        // merged into training and prediction files).
+        $analysabledataset = new \core_analytics\dataset_manager($fakemodelid, 1, 'whatever',
+            \core_analytics\dataset_manager::LABELLED_FILEAREA, false);
+        $analysabledataset->init_process();
+        $analysabledatasetdata = array_merge($this->sharedtoprows, array(array('yeah', 'yeah', 'yeah')));
+        $file = $analysabledataset->store($analysabledatasetdata);
+        $analysabledataset->close_process();
+
+        // Evaluation files ignored.
+        $evaluationdataset = \core_analytics\dataset_manager::merge_datasets(array($file), $fakemodelid,
+            '\core\analytics\time_splitting\quarters', \core_analytics\dataset_manager::LABELLED_FILEAREA, true);
+
+        $this->assertEmpty(\core_analytics\dataset_manager::get_pending_files($fakemodelid, true, $timesplittingids));
+        $this->assertEmpty(\core_analytics\dataset_manager::get_pending_files($fakemodelid, false, $timesplittingids));
+
+        // Training and prediction files are not mixed up.
+        $trainingfile1 = \core_analytics\dataset_manager::merge_datasets(array($file), $fakemodelid,
+            '\core\analytics\time_splitting\quarters', \core_analytics\dataset_manager::LABELLED_FILEAREA, false);
+        $trainingfile2 = \core_analytics\dataset_manager::merge_datasets(array($file), $fakemodelid,
+            '\core\analytics\time_splitting\quarters', \core_analytics\dataset_manager::LABELLED_FILEAREA, false);
+
+        $bytimesplitting = \core_analytics\dataset_manager::get_pending_files($fakemodelid, true, $timesplittingids);
+        $this->assertFalse(isset($bytimesplitting['\core\analytics\time_splitting\quarters_accum']));
+        $this->assertCount(2, $bytimesplitting['\core\analytics\time_splitting\quarters']);
+        $this->assertEmpty(\core_analytics\dataset_manager::get_pending_files($fakemodelid, false, $timesplittingids));
+
+        $predictionfile = \core_analytics\dataset_manager::merge_datasets(array($file), $fakemodelid,
+            '\core\analytics\time_splitting\quarters', \core_analytics\dataset_manager::UNLABELLED_FILEAREA, false);
+        $bytimesplitting = \core_analytics\dataset_manager::get_pending_files($fakemodelid, false, $timesplittingids);
+        $this->assertFalse(isset($bytimesplitting['\core\analytics\time_splitting\quarters_accum']));
+        $this->assertCount(1, $bytimesplitting['\core\analytics\time_splitting\quarters']);
+
+        // Already used for training and prediction are discarded.
+        $usedfile = (object)['modelid' => $fakemodelid, 'fileid' => $trainingfile1->get_id(), 'action' => 'trained',
+            'time' => time()];
+        $DB->insert_record('analytics_used_files', $usedfile);
+        $bytimesplitting = \core_analytics\dataset_manager::get_pending_files($fakemodelid, true, $timesplittingids);
+        $this->assertCount(1, $bytimesplitting['\core\analytics\time_splitting\quarters']);
+
+        $usedfile->fileid = $predictionfile->get_id();
+        $usedfile->action = 'predicted';
+        $DB->insert_record('analytics_used_files', $usedfile);
+        $this->assertEmpty(\core_analytics\dataset_manager::get_pending_files($fakemodelid, false, $timesplittingids));
+    }
 }
diff --git a/analytics/tests/fixtures/test_analyser.php b/analytics/tests/fixtures/test_analyser.php
new file mode 100644 (file)
index 0000000..736388c
--- /dev/null
@@ -0,0 +1,48 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Test analyser.
+ *
+ * @package   core_analytics
+ * @copyright 2017 David MonllaĆ³ {@link http://www.davidmonllao.com}
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Test analyser.
+ *
+ * @package   core_analytics
+ * @copyright 2017 David MonllaĆ³ {@link http://www.davidmonllao.com}
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class test_analyser extends \core\analytics\analyser\courses {
+
+    /**
+     * Overwritten to add a delay.
+     *
+     * @param \core_analytics\analysable $analysable
+     * @param mixed $includetarget
+     * @return null
+     */
+    public function process_analysable($analysable, $includetarget) {
+        // A bit more than 1 second.
+        usleep(1100000);
+        return parent::process_analysable($analysable, $includetarget);
+    }
+}
index fcb7eac..2685b64 100644 (file)
@@ -28,6 +28,8 @@ 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_target_course_level_shortname.php');
+require_once(__DIR__ . '/fixtures/test_analyser.php');
 
 /**
  * Unit tests for the model.
@@ -153,13 +155,10 @@ class analytics_model_testcase extends advanced_testcase {
         $modelversionoutputdir = $this->model->get_output_dir();
         $this->assertTrue(is_dir($modelversionoutputdir));
 
-        // Update to an empty time splitting method to force clear_model execution.
-        $this->model->update(1, false, '');
+        // Update to an empty time splitting method to force model::clear execution.
+        $this->model->clear();
         $this->assertFalse(is_dir($modelversionoutputdir));
 
-        // Restore previous time splitting method.
-        $this->model->enable('\core\analytics\time_splitting\no_splitting');
-
         // Check that most of the stuff got deleted.
         $this->assertEquals(1, $DB->count_records('analytics_models', array('id' => $this->modelobj->id)));
         $this->assertEquals(1, $DB->count_records('analytics_models_log', array('modelid' => $this->modelobj->id)));
@@ -256,6 +255,64 @@ class analytics_model_testcase extends advanced_testcase {
         $this->assertTrue(\core_analytics\model::exists($target));
     }
 
+    /**
+     * test_model_timelimit
+     *
+     * @return null
+     */
+    public function test_model_timelimit() {
+        global $DB;
+
+        $this->resetAfterTest(true);
+
+        set_config('modeltimelimit', 2, 'analytics');
+
+        $courses = array();
+        for ($i = 0; $i < 5; $i++) {
+            $course = $this->getDataGenerator()->create_course();
+            $analysable = new \core_analytics\course($course);
+            $courses[$analysable->get_id()] = $course;
+        }
+
+        $target = new test_target_course_level_shortname();
+        $analyser = new test_analyser(1, $target, [], [], []);
+
+        // Each analysable element takes 1.1 secs, so the max (and likely) number of analysable
+        // elements that will be processed is 2.
+        $analyser->get_analysable_data(false);
+        $params = array('modelid' => 1, 'action' => 'prediction');
+        $this->assertLessThanOrEqual(2, $DB->count_records('analytics_used_analysables', $params));
+
+        $analyser->get_analysable_data(false);
+        $this->assertLessThanOrEqual(4, $DB->count_records('analytics_used_analysables', $params));
+
+        // Check that analysable elements have been processed following the analyser order
+        // (course->sortorder here). We can not check this nicely after next get_analysable_data round
+        // because the first analysed element will be analysed again.
+        $analysedelems = $DB->get_records('analytics_used_analysables', $params, 'timeanalysed ASC');
+        // Just a default for the first checked element.
+        $last = (object)['sortorder' => PHP_INT_MAX];
+        foreach ($analysedelems as $analysed) {
+            if ($courses[$analysed->analysableid]->sortorder > $last->sortorder) {
+                $this->fail('Analysable elements have not been analysed sorted by course sortorder.');
+            }
+            $last = $courses[$analysed->analysableid];
+        }
+
+        $analyser->get_analysable_data(false);
+        $this->assertGreaterThanOrEqual(5, $DB->count_records('analytics_used_analysables', $params));
+
+        // New analysable elements are immediately pulled.
+        $this->getDataGenerator()->create_course();
+        $analyser->get_analysable_data(false);
+        $this->assertGreaterThanOrEqual(6, $DB->count_records('analytics_used_analysables', $params));
+
+        // Training and prediction data do not get mixed.
+        $analyser->get_analysable_data(true);
+        $params = array('modelid' => 1, 'action' => 'training');
+        $this->assertLessThanOrEqual(2, $DB->count_records('analytics_used_analysables', $params));
+    }
+
     /**
      * Generates a model log record.
      */
index 7f30037..f97db57 100644 (file)
@@ -273,6 +273,96 @@ class core_analytics_prediction_testcase extends advanced_testcase {
         return $this->add_prediction_processors($cases);
     }
 
+    /**
+     * Test the system classifiers returns.
+     *
+     * This test checks that all mlbackend plugins in the system are able to return proper status codes
+     * even under weird situations.
+     *
+     * @dataProvider provider_ml_classifiers_return
+     * @param int $success
+     * @param int $nsamples
+     * @param int $classes
+     * @param string $predictionsprocessorclass
+     * @return void
+     */
+    public function test_ml_classifiers_return($success, $nsamples, $classes, $predictionsprocessorclass) {
+        $this->resetAfterTest();
+
+        $predictionsprocessor = \core_analytics\manager::get_predictions_processor($predictionsprocessorclass, false);
+        if ($predictionsprocessor->is_ready() !== true) {
+            $this->markTestSkipped('Skipping ' . $predictionsprocessorclass . ' as the predictor is not ready.');
+        }
+
+        if ($nsamples % count($classes) != 0) {
+            throw new \coding_exception('The number of samples should be divisible by the number of classes');
+        }
+        $samplesperclass = $nsamples / count($classes);
+
+        // Metadata (we pass 2 classes even if $classes only provides 1 class samples as we want to test
+        // what the backend does in this case.
+        $dataset = "nfeatures,targetclasses,targettype" . PHP_EOL;
+        $dataset .= "3,\"[0,1]\",\"discrete\"" . PHP_EOL;
+
+        // Headers.
+        $dataset .= "feature1,feature2,feature3,target" . PHP_EOL;
+        foreach ($classes as $class) {
+            for ($i = 0; $i < $samplesperclass; $i++) {
+                $dataset .= "1,0,1,$class" . PHP_EOL;
+            }
+        }
+
+        $trainingfile = array(
+            'contextid' => \context_system::instance()->id,
+            'component' => 'analytics',
+            'filearea' => 'labelled',
+            'itemid' => 123,
+            'filepath' => '/',
+            'filename' => 'whocares.csv'
+        );
+        $fs = get_file_storage();
+        $dataset = $fs->create_file_from_string($trainingfile, $dataset);
+
+        // Training should work correctly if at least 1 sample of each class is included.
+        $dir = make_request_directory();
+        $result = $predictionsprocessor->train_classification('whatever', $dataset, $dir);
+
+        switch ($success) {
+            case 'yes':
+                $this->assertEquals(\core_analytics\model::OK, $result->status);
+                break;
+            case 'no':
+                $this->assertNotEquals(\core_analytics\model::OK, $result->status);
+                break;
+            case 'maybe':
+            default:
+                // We just check that an object is returned so we don't have an empty check,
+                // what we really want to check is that an exception was not thrown.
+                $this->assertInstanceOf(\stdClass::class, $result);
+        }
+    }
+
+    /**
+     * test_ml_classifiers_return provider
+     *
+     * We can not be very specific here as test_ml_classifiers_return only checks that
+     * mlbackend plugins behave and expected and control properly backend errors even
+     * under weird situations.
+     *
+     * @return array
+     */
+    public function provider_ml_classifiers_return() {
+        // Using verbose options as the first argument for readability.
+        $cases = array(
+            '1-samples' => array('maybe', 1, [0]),
+            '2-samples-same-class' => array('maybe', 2, [0]),
+            '2-samples-different-classes' => array('yes', 2, [0, 1]),
+            '4-samples-different-classes' => array('yes', 4, [0, 1])
+        );
+
+        // We need to test all system prediction processors.
+        return $this->add_prediction_processors($cases);
+    }
 
     /**
      * Basic test to check that prediction processors work as expected.
@@ -426,8 +516,8 @@ class core_analytics_prediction_testcase extends advanced_testcase {
                 'expectedresults' => array(
                     // The course duration is too much to be processed by in weekly basis.
                     '\core\analytics\time_splitting\weekly' => \core_analytics\model::NO_DATASET,
-                    '\core\analytics\time_splitting\single_range' => \core_analytics\model::EVALUATE_LOW_SCORE,
-                    '\core\analytics\time_splitting\quarters' => \core_analytics\model::EVALUATE_LOW_SCORE,
+                    '\core\analytics\time_splitting\single_range' => \core_analytics\model::LOW_SCORE,
+                    '\core\analytics\time_splitting\quarters' => \core_analytics\model::LOW_SCORE,
                 )
             ),
             'good' => array(
index 86ce136..9c171ae 100644 (file)
@@ -42,6 +42,9 @@ $url = new moodle_url('/backup/backupfilesedit.php', array('currentcontext'=>$cu
 
 require_login($course, false, $cm);
 require_capability('moodle/restore:uploadfile', $context);
+if ($filearea == 'automated' && !can_download_from_backup_filearea($filearea, $context)) {
+    throw new required_capability_exception($context, 'moodle/backup:downloadfile', 'nopermissions', '');
+}
 
 $PAGE->set_url($url);
 $PAGE->set_context($context);
index a1b9b8b..6d5e6a5 100644 (file)
@@ -182,6 +182,15 @@ abstract class restore_activity_task extends restore_task {
         // Activity competencies.
         $this->add_step(new restore_activity_competencies_structure_step('activity_competencies', 'competencies.xml'));
 
+        // Search reindexing, if enabled and if not restoring entire course.
+        if (\core_search\manager::is_indexing_enabled()) {
+            $wholecourse = $this->get_target() == backup::TARGET_NEW_COURSE;
+            $wholecourse = $wholecourse || ($this->setting_exists('overwrite_conf') && $this->get_setting_value('overwrite_conf'));
+            if (!$wholecourse) {
+                $this->add_step(new restore_activity_search_index('activity_search_index'));
+            }
+        }
+
         // At the end, mark it as built
         $this->built = true;
     }
index 64e5b99..eb5cd47 100644 (file)
@@ -98,6 +98,15 @@ abstract class restore_block_task extends restore_task {
             $this->add_step(new restore_comments_structure_step('block_comments', 'comments.xml'));
         }
 
+        // Search reindexing (if enabled).
+        if (\core_search\manager::is_indexing_enabled()) {
+            $wholecourse = $this->get_target() == backup::TARGET_NEW_COURSE;
+            $wholecourse = $wholecourse || $this->setting_exists('overwrite_conf') && $this->get_setting_value('overwrite_conf');
+            if (!$wholecourse) {
+                $this->add_step(new restore_block_search_index('block_search_index'));
+            }
+        }
+
         // At the end, mark it as built
         $this->built = true;
     }
index 74bfa6c..2121c02 100644 (file)
@@ -69,6 +69,11 @@ class restore_course_task extends restore_task {
         // Executed conditionally if restoring to new course or if overwrite_conf setting is enabled
         if ($this->get_target() == backup::TARGET_NEW_COURSE || $this->get_setting_value('overwrite_conf') == true) {
             $this->add_step(new restore_course_structure_step('course_info', 'course.xml'));
+
+            // Search reindexing (if enabled).
+            if (\core_search\manager::is_indexing_enabled()) {
+                $this->add_step(new restore_course_search_index('course_search_index'));
+            }
         }
 
         $this->add_step(new restore_course_legacy_files_step('legacy_files'));
index 1bb3e51..b0b6b76 100644 (file)
@@ -114,6 +114,15 @@ class restore_final_task extends restore_task {
         // Clean the temp dir (conditionally) and drop temp table
         $this->add_step(new restore_drop_and_clean_temp_stuff('drop_and_clean_temp_stuff'));
 
+        // If restoring to a new course or overwriting config, reindex the whole course.
+        if (\core_search\manager::is_indexing_enabled()) {
+            $wholecourse = $this->get_target() == backup::TARGET_NEW_COURSE;
+            $wholecourse = $wholecourse || $this->setting_exists('overwrite_conf') && $this->get_setting_value('overwrite_conf');
+            if ($wholecourse) {
+                $this->add_step(new restore_course_search_index('course_search_index'));
+            }
+        }
+
         $this->built = true;
     }
 
index 10872f8..b53ffa6 100644 (file)
@@ -5544,6 +5544,58 @@ class restore_completion_defaults_structure_step extends restore_structure_step
         $this->set_mapping('course_completion_defaults', $oldid, $newid);
     }
 }
+
+/**
+ * Index course after restore.
+ *
+ * @package core_backup
+ * @copyright 2017 The Open University
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class restore_course_search_index extends restore_execution_step {
+    /**
+     * When this step is executed, we add the course context to the queue for reindexing.
+     */
+    protected function define_execution() {
+        $context = \context_course::instance($this->task->get_courseid());
+        \core_search\manager::request_index($context);
+    }
+}
+
+/**
+ * Index activity after restore (when not restoring whole course).
+ *
+ * @package core_backup
+ * @copyright 2017 The Open University
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class restore_activity_search_index extends restore_execution_step {
+    /**
+     * When this step is executed, we add the activity context to the queue for reindexing.
+     */
+    protected function define_execution() {
+        $context = \context::instance_by_id($this->task->get_contextid());
+        \core_search\manager::request_index($context);
+    }
+}
+
+/**
+ * Index block after restore (when not restoring whole course).
+ *
+ * @package core_backup
+ * @copyright 2017 The Open University
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class restore_block_search_index extends restore_execution_step {
+    /**
+     * When this step is executed, we add the block context to the queue for reindexing.
+     */
+    protected function define_execution() {
+        $context = \context_block::instance($this->task->get_blockid());
+        \core_search\manager::request_index($context);
+    }
+}
+
 /**
  * Restore action events.
  *
index 21773b9..3b45eea 100644 (file)
@@ -867,4 +867,92 @@ class core_backup_moodle2_testcase extends advanced_testcase {
         $this->assertTrue($before <= $blockdata->timecreated && $after >= $blockdata->timecreated);
         $this->assertTrue($before <= $blockdata->timemodified && $after >= $blockdata->timemodified);
     }
+
+    /**
+     * When you restore a site with global search (or search indexing) turned on, then it should
+     * add entries to the search index requests table so that the data gets indexed.
+     */
+    public function test_restore_search_index_requests() {
+        global $DB, $CFG, $USER;
+
+        $this->resetAfterTest(true);
+        $this->setAdminUser();
+        $CFG->enableglobalsearch = true;
+
+        // Create a course.
+        $generator = $this->getDataGenerator();
+        $course = $generator->create_course();
+
+        // Add a forum.
+        $forum = $generator->create_module('forum', ['course' => $course->id]);
+
+        // Add a block.
+        $context = context_course::instance($course->id);
+        $page = new moodle_page();
+        $page->set_context($context);
+        $page->set_course($course);
+        $page->set_pagelayout('standard');
+        $page->set_pagetype('course-view');
+        $page->blocks->load_blocks();
+        $page->blocks->add_block_at_end_of_default_region('html');
+
+        // Initially there should be no search index requests.
+        $this->assertEquals(0, $DB->count_records('search_index_requests'));
+
+        // Do backup and restore.
+        $newcourseid = $this->backup_and_restore($course);
+
+        // Now the course should be requested for index (all search areas).
+        $newcontext = context_course::instance($newcourseid);
+        $requests = array_values($DB->get_records('search_index_requests'));
+        $this->assertCount(1, $requests);
+        $this->assertEquals($newcontext->id, $requests[0]->contextid);
+        $this->assertEquals('', $requests[0]->searcharea);
+
+        get_fast_modinfo($newcourseid);
+
+        // Backup the new course...
+        $CFG->backup_file_logger_level = backup::LOG_NONE;
+        $bc = new backup_controller(backup::TYPE_1COURSE, $newcourseid,
+                backup::FORMAT_MOODLE, backup::INTERACTIVE_NO, backup::MODE_IMPORT,
+                $USER->id);
+        $backupid = $bc->get_backupid();
+        $bc->execute_plan();
+        $bc->destroy();
+
+        // Restore it on top of old course (should duplicate the forum).
+        $rc = new restore_controller($backupid, $course->id,
+                backup::INTERACTIVE_NO, backup::MODE_GENERAL, $USER->id,
+                backup::TARGET_EXISTING_ADDING);
+        $this->assertTrue($rc->execute_precheck());
+        $rc->execute_plan();
+        $rc->destroy();
+
+        // Get the forums now on the old course.
+        $modinfo = get_fast_modinfo($course->id);
+        $forums = $modinfo->get_instances_of('forum');
+        $this->assertCount(2, $forums);
+
+        // The newer one will be the one with larger ID. (Safe to assume for unit test.)
+        $biggest = null;
+        foreach ($forums as $forum) {
+            if ($biggest === null || $biggest->id < $forum->id) {
+                $biggest = $forum;
+            }
+        }
+        $restoredforumcontext = \context_module::instance($biggest->id);
+
+        // Get the HTML blocks now on the old course.
+        $blockdata = array_values($DB->get_records('block_instances',
+                ['blockname' => 'html', 'parentcontextid' => $context->id], 'id DESC'));
+        $restoredblockcontext = \context_block::instance($blockdata[0]->id);
+
+        // Check that we have requested index update on both the module and the block.
+        $requests = array_values($DB->get_records('search_index_requests', null, 'id'));
+        $this->assertCount(3, $requests);
+        $this->assertEquals($restoredblockcontext->id, $requests[1]->contextid);
+        $this->assertEquals('', $requests[1]->searcharea);
+        $this->assertEquals($restoredforumcontext->id, $requests[2]->contextid);
+        $this->assertEquals('', $requests[2]->searcharea);
+    }
 }
index 7b0fcd2..be9ae4a 100644 (file)
@@ -79,6 +79,9 @@ if (!check_dir_exists($tmpdir, true, true)) {
 
 // choose the backup file from backup files tree
 if ($action == 'choosebackupfile') {
+    if ($filearea == 'automated') {
+        require_capability('moodle/restore:viewautomatedfilearea', $context);
+    }
     if ($fileinfo = $browser->get_file_info($filecontext, $component, $filearea, $itemid, $filepath, $filename)) {
         if (is_a($fileinfo, 'file_info_stored')) {
             // Use the contenthash rather than copying the file where possible,
index b4855bb..7499d6f 100644 (file)
@@ -573,27 +573,51 @@ class core_backup_renderer extends plugin_renderer_base {
             $params['contextid'] = $viewer->currentcontext->id;
             $params['itemid'] = $file->get_itemid();
             $restoreurl = new moodle_url('/backup/restorefile.php', $params);
+            $restorelink = html_writer::link($restoreurl, get_string('restore'));
+            $downloadlink = html_writer::link($fileurl, get_string('download'));
+
+            // Conditional display of the restore and download links, initially only for the 'automated' filearea.
+            if ($params['filearea'] == 'automated') {
+                if (!has_capability('moodle/restore:viewautomatedfilearea', $viewer->currentcontext)) {
+                    $restorelink = '';
+                }
+                if (!can_download_from_backup_filearea($params['filearea'], $viewer->currentcontext)) {
+                    $downloadlink = '';
+                }
+            }
             $table->data[] = array(
                 $file->get_filename(),
                 userdate($file->get_timemodified()),
                 display_size($file->get_filesize()),
-                html_writer::link($fileurl, get_string('download')),
-                html_writer::link($restoreurl, get_string('restore')),
+                $downloadlink,
+                $restorelink,
                 );
         }
 
         $html = html_writer::table($table);
-        $html .= $this->output->single_button(
-            new moodle_url('/backup/backupfilesedit.php', array(
-                'currentcontext' => $viewer->currentcontext->id,
-                'contextid' => $viewer->filecontext->id,
-                'filearea' => $viewer->filearea,
-                'component' => $viewer->component,
-                'returnurl' => $this->page->url->out())
-            ),
-            get_string('managefiles', 'backup'),
-            'post'
-        );
+
+        // For automated backups, the ability to manage backup files is controlled by the ability to download them.
+        // All files must be from the same file area in a backup_files_viewer.
+        $canmanagebackups = true;
+        if ($viewer->filearea == 'automated') {
+            if (!can_download_from_backup_filearea($viewer->filearea, $viewer->currentcontext)) {
+                $canmanagebackups = false;
+            }
+        }
+
+        if ($canmanagebackups) {
+            $html .= $this->output->single_button(
+                new moodle_url('/backup/backupfilesedit.php', array(
+                        'currentcontext' => $viewer->currentcontext->id,
+                        'contextid' => $viewer->filecontext->id,
+                        'filearea' => $viewer->filearea,
+                        'component' => $viewer->component,
+                        'returnurl' => $this->page->url->out())
+                ),
+                get_string('managefiles', 'backup'),
+                'post'
+            );
+        }
 
         return $html;
     }
index 858a0e4..0bc46c9 100644 (file)
@@ -46,106 +46,36 @@ class block_calendar_upcoming extends block_base {
         $this->content = new stdClass;
         $this->content->text = '';
 
-        $filtercourse    = array();
-        if (empty($this->instance)) { // Overrides: use no course at all.
-            $courseshown = false;
-            $this->content->footer = '';
-
+        $renderer = $this->page->get_renderer('core_calendar');
+        $courseid = $this->page->course->id;
+        $issite = ($courseid == SITEID);
+
+        if ($issite) {
+            // Being displayed at site level. This will cause the filter to fall back to auto-detecting
+            // the list of courses it will be grabbing events from.
+            $course = get_site();
+            $courses = calendar_get_default_courses();
         } else {
-            $courseshown = $this->page->course->id;
-            $this->content->footer = '<div class="gotocal"><a href="'.$CFG->wwwroot.
-                                     '/calendar/view.php?view=upcoming&amp;course='.$courseshown.'">'.
-                                      get_string('gotocalendar', 'calendar').'</a>...</div>';
-            $context = context_course::instance($courseshown);
-            if (has_any_capability(array('moodle/calendar:manageentries', 'moodle/calendar:manageownentries'), $context)) {
-                $this->content->footer .= '<div class="newevent"><a href="'.$CFG->wwwroot.
-                                          '/calendar/event.php?action=new&amp;course='.$courseshown.'">'.
-                                           get_string('newevent', 'calendar').'</a>...</div>';
-            }
-            if ($courseshown == SITEID) {
-                // Being displayed at site level. This will cause the filter to fall back to auto-detecting
-                // the list of courses it will be grabbing events from.
-                $filtercourse = calendar_get_default_courses();
-            } else {
-                // Forcibly filter events to include only those from the particular course we are in.
-                $filtercourse = array($courseshown => $this->page->course);
-            }
-        }
-
-        list($courses, $group, $user) = calendar_set_filters($filtercourse);
-
-        $defaultlookahead = CALENDAR_DEFAULT_UPCOMING_LOOKAHEAD;
-        if (isset($CFG->calendar_lookahead)) {
-            $defaultlookahead = intval($CFG->calendar_lookahead);
-        }
-        $lookahead = get_user_preferences('calendar_lookahead', $defaultlookahead);
-
-        $defaultmaxevents = CALENDAR_DEFAULT_UPCOMING_MAXEVENTS;
-        if (isset($CFG->calendar_maxevents)) {
-            $defaultmaxevents = intval($CFG->calendar_maxevents);
+            // Forcibly filter events to include only those from the particular course we are in.
+            $course = $this->page->course;
+            $courses = [$course->id => $course];
         }
-        $maxevents = get_user_preferences('calendar_maxevents', $defaultmaxevents);
-        $events = calendar_get_upcoming($courses, $group, $user, $lookahead, $maxevents);
+        $calendar = new calendar_information(0, 0, 0, time());
+        $calendar->set_sources($course, $courses);
 
-        if (!empty($this->instance)) {
-            $link = 'view.php?view=day&amp;course='.$courseshown.'&amp;';
-            $showcourselink = ($this->page->course->id == SITEID);
-            $this->content->text = self::get_upcoming_content($events, $link, $showcourselink);
-        }
+        list($data, $template) = calendar_get_view($calendar, 'upcoming_mini');
 
-        if (empty($this->content->text)) {
+        if (empty($data->events)) {
             $this->content->text = '<div class="post">'. get_string('noupcomingevents', 'calendar').'</div>';
+        } else {
+            $this->content->text .= $renderer->render_from_template($template, $data);
         }
 
-        return $this->content;
-    }
-
-    /**
-     * Get the upcoming event block content.
-     *
-     * @param array $events list of events
-     * @param \moodle_url|string $linkhref link to event referer
-     * @param boolean $showcourselink whether links to courses should be shown
-     * @return string|null $content html block content
-     */
-    public static function get_upcoming_content($events, $linkhref = null, $showcourselink = false) {
-        $content = '';
-        $lines = count($events);
-
-        if (!$lines) {
-            return $content;
-        }
-
-        for ($i = 0; $i < $lines; ++$i) {
-            if (!isset($events[$i]->time)) {
-                continue;
-            }
-            $events[$i] = calendar_add_event_metadata($events[$i]);
-            $content .= '<div class="event"><span class="icon c0">' . $events[$i]->icon . '</span>';
-            if (!empty($events[$i]->referer)) {
-                // That's an activity event, so let's provide the hyperlink.
-                $content .= $events[$i]->referer;
-            } else {
-                if (!empty($linkhref)) {
-                    $href = calendar_get_link_href(new \moodle_url(CALENDAR_URL . $linkhref), 0, 0, 0,
-                        $events[$i]->timestart);
-                    $href->set_anchor('event_' . $events[$i]->id);
-                    $content .= \html_writer::link($href, $events[$i]->name);
-                } else {
-                    $content .= $events[$i]->name;
-                }
-            }
-            $events[$i]->time = str_replace('&raquo;', '<br />&raquo;', $events[$i]->time);
-            if ($showcourselink && !empty($events[$i]->courselink)) {
-                $content .= \html_writer::div($events[$i]->courselink, 'course');
-            }
-            $content .= '<div class="date">' . $events[$i]->time . '</div></div>';
-            if ($i < $lines - 1) {
-                $content .= '<hr />';
-            }
-        }
+        $this->content->footer = '<div class="gotocal">
+                <a href="'.$CFG->wwwroot.'/calendar/view.php?view=upcoming&amp;course='.$courseid.'">'.
+                get_string('gotocalendar', 'calendar').'</a>...</div>';
 
-        return $content;
+        return $this->content;
     }
 }
 
index da91072..fbec430 100644 (file)
Binary files a/calendar/amd/build/calendar.min.js and b/calendar/amd/build/calendar.min.js differ
index ef94d15..ddf2010 100644 (file)
Binary files a/calendar/amd/build/calendar_filter.min.js and b/calendar/amd/build/calendar_filter.min.js differ
index 1f6e3ff..0abfe87 100644 (file)
Binary files a/calendar/amd/build/event_form.min.js and b/calendar/amd/build/event_form.min.js differ
index 4f10cb0..62ce49d 100644 (file)
@@ -257,10 +257,12 @@ define([
             var target = $(e.target);
             var eventId = null;
 
-            if (target.is(SELECTORS.EVENT_LINK)) {
-                eventId = target.attr('data-event-id');
+            var eventLink = target.closest(SELECTORS.EVENT_LINK);
+
+            if (eventLink.length) {
+                eventId = eventLink.data('eventId');
             } else {
-                eventId = target.find(SELECTORS.EVENT_LINK).attr('data-event-id');
+                eventId = target.find(SELECTORS.EVENT_LINK).data('eventId');
             }
 
             renderEventSummaryModal(eventId);
index 5352a7c..622dafd 100644 (file)
@@ -64,7 +64,7 @@ function(
         // Toggle the hidden. We need to render the template before we change the value.
         data.hidden = !data.hidden;
 
-        return Str.get_string('eventtype' + data.type, 'calendar')
+        return Str.get_string('eventtype' + data.eventtype, 'calendar')
         .then(function(nameStr) {
             data.name = nameStr;
 
@@ -90,7 +90,7 @@ function(
     var fireFilterChangedEvent = function(data) {
         M.util.js_pending("month-mini-filterChanged");
         $('body').trigger(CalendarEvents.filterChanged, {
-            type: data.type,
+            type: data.eventtype,
             hidden: data.hidden,
         });
         M.util.js_complete("month-mini-filterChanged");
@@ -104,7 +104,7 @@ function(
      */
     var getFilterData = function(target) {
         return {
-            type: target.data('eventtype'),
+            eventtype: target.data('eventtype'),
             hidden: target.data('eventtype-hidden'),
         };
     };
index f537f6d..2b6707d 100644 (file)
@@ -74,14 +74,14 @@ define(['jquery'], function($) {
         var filterGroupSelectOptions = function() {
             var selectedCourseId = courseGroupSelect.val();
             var selectedIndex = null;
-
+            var hasGroups = false;
             groupSelectOptions.each(function(index, element) {
                 element = $(element);
 
                 if (element.attr('data-course-id') == selectedCourseId) {
                     element.removeClass('hidden');
                     element.prop('disabled', false);
-
+                    hasGroups = true;
                     if (selectedIndex === null || element.attr('selected')) {
                         selectedIndex = index;
                     }
@@ -91,6 +91,12 @@ define(['jquery'], function($) {
                 }
             });
 
+            if (hasGroups) {
+                groupSelect.prop('disabled', false);
+            } else {
+                groupSelect.prop('disabled', true);
+            }
+
             groupSelect.prop('selectedIndex', selectedIndex);
         };
 
index 85457e2..0f17777 100644 (file)
@@ -208,6 +208,9 @@ class event_exporter_base extends exporter {
             'editurl' => [
                 'type' => PARAM_URL
             ],
+            'viewurl' => [
+                'type' => PARAM_URL
+            ],
             'formattedtime' => [
                 'type' => PARAM_RAW,
             ],
@@ -280,6 +283,10 @@ class event_exporter_base extends exporter {
         $editurl = new moodle_url('/calendar/event.php', ['action' => 'edit', 'id' => $event->get_id(),
                 'course' => $courseid]);
         $values['editurl'] = $editurl->out(false);
+        $viewurl = new moodle_url('/calendar/view.php', ['view' => 'day', 'course' => $courseid,
+                'time' => $timesort]);
+        $viewurl->set_anchor('event_' . $event->get_id());
+        $values['viewurl'] = $viewurl->out(false);
         $values['formattedtime'] = calendar_format_event_time($legacyevent, time(), null, false,
                 $timesort);
 
index e4478bb..9cce687 100644 (file)
@@ -76,18 +76,6 @@ class footer_options_exporter extends exporter {
         return new \single_button($exportcalendarurl, get_string('exportcalendar', 'calendar'));
     }
 
-    /**
-     * Get the iCal url.
-     *
-     * @return string The iCal url.
-     */
-    protected function get_ical_url() {
-        if ($this->token) {
-            return new moodle_url('/calendar/export_execute.php', ['preset_what' => 'all',
-                    'preset_time' => 'recentupcoming', 'userid' => $this->userid, 'authtoken' => $this->token]);
-        }
-    }
-
     /**
      * Get manage subscription button.
      *
@@ -120,7 +108,6 @@ class footer_options_exporter extends exporter {
             if ($managesubscriptionbutton = $this->get_manage_subscriptions_button()) {
                 $values->managesubscriptionbutton = $managesubscriptionbutton->export_for_template($output);
             }
-            $values->icalurl = $this->get_ical_url()->out(false);
         }
 
         return (array) $values;
@@ -141,10 +128,6 @@ class footer_options_exporter extends exporter {
                 'type' => PARAM_RAW,
                 'default' => null,
             ],
-            'icalurl' => [
-                'type' => PARAM_URL,
-                'default' => null,
-            ],
         );
     }
 }
index 8892bfb..0beb6e0 100644 (file)
@@ -132,6 +132,14 @@ class create extends \moodleform {
             }
         }
 
+        if ($eventtype == 'course' && empty($data['courseid'])) {
+            $errors['courseid'] = get_string('selectacourse');
+        }
+
+        if ($eventtype == 'group' && empty($data['groupcourseid'])) {
+            $errors['groupcourseid'] = get_string('selectacourse');
+        }
+
         if ($data['duration'] == 1 && $data['timestart'] > $data['timedurationuntil']) {
             $errors['durationgroup'] = get_string('invalidtimedurationuntil', 'calendar');
         } else if ($data['duration'] == 2 && (trim($data['timedurationminutes']) == '' || $data['timedurationminutes'] < 1)) {
@@ -237,24 +245,19 @@ class create extends \moodleform {
         }
 
         if (isset($eventtypes['course'])) {
-            $courseoptions = [];
-            foreach ($eventtypes['course'] as $course) {
-                $courseoptions[$course->id] = format_string($course->fullname, true,
-                    ['context' => \context_course::instance($course->id)]);
-            }
-
-            $mform->addElement('select', 'courseid', get_string('course'), $courseoptions);
+            $mform->addElement('course', 'courseid', get_string('course'), ['limittoenrolled' => true]);
             $mform->hideIf('courseid', 'eventtype', 'noteq', 'course');
         }
 
         if (isset($eventtypes['group'])) {
-            $courseoptions = [];
-            foreach ($eventtypes['groupcourses'] as $course) {
-                $courseoptions[$course->id] = format_string($course->fullname, true,
-                    ['context' => \context_course::instance($course->id)]);
+            $options = ['limittoenrolled' => true];
+            // Exclude courses without group.
+            if (isset($eventtypes['course']) && isset($eventtypes['groupcourses'])) {
+                $options['exclude'] = array_diff(array_keys($eventtypes['course']),
+                    array_keys($eventtypes['groupcourses']));
             }
 
-            $mform->addElement('select', 'groupcourseid', get_string('course'), $courseoptions);
+            $mform->addElement('course', 'groupcourseid', get_string('course'), $options);
             $mform->hideIf('groupcourseid', 'eventtype', 'noteq', 'group');
 
             $groupoptions = [];
@@ -268,6 +271,7 @@ class create extends \moodleform {
 
             $mform->addElement('select', 'groupid', get_string('group'), $groupoptions);
             $mform->hideIf('groupid', 'eventtype', 'noteq', 'group');
+            // We handle the group select hide/show actions on the event_form module.
         }
     }
 
index 75a688f..91c1435 100644 (file)
@@ -61,7 +61,10 @@ class create_update_form_mapper implements create_update_form_mapper_interface {
             $data->groupid = "{$legacyevent->courseid}-{$legacyevent->groupid}";
             $data->groupcourseid = $legacyevent->courseid;
         }
-
+        if ($legacyevent->eventtype == 'course') {
+            // Set up the correct value for the to display on the form.
+            $data->courseid = $legacyevent->courseid;
+        }
         $data->description = [
             'text' => $data->description,
             'format' => $data->format
@@ -85,26 +88,28 @@ class create_update_form_mapper implements create_update_form_mapper_interface {
     public function from_data_to_event_properties(\stdClass $data) {
         $properties = clone($data);
 
-        // Undo the form definition work around to allow us to have two different
-        // course selectors present depending on which event type the user selects.
-        if (isset($data->groupcourseid)) {
-            $properties->courseid = $data->groupcourseid;
-            unset($properties->groupcourseid);
-        }
-
-        // Pull the group id back out of the value. The form saves the value
-        // as "<courseid>-<groupid>" to allow the javascript to work correctly.
-        if (isset($data->groupid)) {
-            list($courseid, $groupid) = explode('-', $data->groupid);
-            $properties->groupid = $groupid;
-        }
+        if ($data->eventtype == 'group') {
+            if (isset($data->groupcourseid)) {
+                $properties->courseid = $data->groupcourseid;
+                unset($properties->groupcourseid);
+            }
 
-        // Default course id if none is set.
-        if (!isset($properties->courseid)) {
-            if ($properties->eventtype === 'site') {
-                $properties->courseid = SITEID;
+            // Pull the group id back out of the value. The form saves the value
+            // as "<courseid>-<groupid>" to allow the javascript to work correctly.
+            if (isset($data->groupid)) {
+                list($courseid, $groupid) = explode('-', $data->groupid);
+                $properties->groupid = $groupid;
+            }
+        } else {
+            // Default course id if none is set.
+            if (empty($properties->courseid)) {
+                if ($properties->eventtype == 'site') {
+                    $properties->courseid = SITEID;
+                } else {
+                    $properties->courseid = 0;
+                }
             } else {
-                $properties->courseid = 0;
+                $properties->courseid = $data->courseid;
             }
         }
 
index 48754d1..6fc0e13 100644 (file)
@@ -1318,77 +1318,6 @@ function calendar_get_starting_weekday() {
     return $calendartype->get_starting_weekday();
 }
 
-/**
- * Gets the calendar upcoming event.
- *
- * @param array $courses array of courses
- * @param array|int|bool $groups array of groups, group id or boolean for all/no group events
- * @param array|int|bool $users array of users, user id or boolean for all/no user events
- * @param int $daysinfuture number of days in the future we 'll look
- * @param int $maxevents maximum number of events
- * @param int $fromtime start time
- * @return array $output array of upcoming events
- */
-function calendar_get_upcoming($courses, $groups, $users, $daysinfuture, $maxevents, $fromtime=0) {
-    global $COURSE;
-
-    $display = new \stdClass;
-    $display->range = $daysinfuture; // How many days in the future we 'll look.
-    $display->maxevents = $maxevents;
-
-    $output = array();
-
-    $processed = 0;
-    $now = time(); // We 'll need this later.
-    $usermidnighttoday = usergetmidnight($now);
-
-    if ($fromtime) {
-        $display->tstart = $fromtime;
-    } else {
-        $display->tstart = $usermidnighttoday;
-    }
-
-    // This works correctly with respect to the user's DST, but it is accurate
-    // only because $fromtime is always the exact midnight of some day!
-    $display->tend = usergetmidnight($display->tstart + DAYSECS * $display->range + 3 * HOURSECS) - 1;
-
-    // Get the events matching our criteria.
-    $events = calendar_get_legacy_events($display->tstart, $display->tend, $users, $groups, $courses);
-
-    // This is either a genius idea or an idiot idea: in order to not complicate things, we use this rule: if, after
-    // possibly removing SITEID from $courses, there is only one course left, then clicking on a day in the month
-    // will also set the $SESSION->cal_courses_shown variable to that one course. Otherwise, we 'd need to add extra
-    // arguments to this function.
-    $hrefparams = array();
-    if (!empty($courses)) {
-        $courses = array_diff($courses, array(SITEID));
-        if (count($courses) == 1) {
-            $hrefparams['course'] = reset($courses);
-        }
-    }
-
-    if ($events !== false) {
-        foreach ($events as $event) {
-            if (!empty($event->modulename)) {
-                $instances = get_fast_modinfo($event->courseid)->get_instances_of($event->modulename);
-                if (empty($instances[$event->instance]->uservisible)) {
-                    continue;
-                }
-            }
-
-            if ($processed >= $display->maxevents) {
-                break;
-            }
-
-            $event->time = calendar_format_event_time($event, $now, $hrefparams);
-            $output[] = $event;
-            $processed++;
-        }
-    }
-
-    return $output;
-}
-
 /**
  * Get a HTML link to a course.
  *
@@ -2478,19 +2407,11 @@ function calendar_get_all_allowed_types() {
         calendar_get_allowed_types($allowed, $course, $coursegroups);
 
         if (!empty($allowed->courses)) {
-            if (!isset($types['course'])) {
-                $types['course'] = [$course];
-            } else {
-                $types['course'][] = $course;
-            }
+            $types['course'][$course->id] = $course;
         }
 
         if (!empty($allowed->groups)) {
-            if (!isset($types['groupcourses'])) {
-                $types['groupcourses'] = [$course];
-            } else {
-                $types['groupcourses'][] = $course;
-            }
+            $types['groupcourses'][$course->id] = $course;
 
             if (!isset($types['group'])) {
                 $types['group'] = array_values($allowed->groups);
@@ -3147,15 +3068,20 @@ function calendar_get_view(\calendar_information $calendar, $view, $includenavig
     if ($view === 'day') {
         $tstart = $type->convert_to_timestamp($date['year'], $date['mon'], $date['mday']);
         $tend = $tstart + DAYSECS - 1;
-    } else if ($view === 'upcoming') {
+    } else if ($view === 'upcoming' || $view === 'upcoming_mini') {
         if (isset($CFG->calendar_lookahead)) {
             $defaultlookahead = intval($CFG->calendar_lookahead);
         } else {
             $defaultlookahead = CALENDAR_DEFAULT_UPCOMING_LOOKAHEAD;
         }
-
+        $lookahead = get_user_preferences('calendar_lookahead', $defaultlookahead);
+        $defaultmaxevents = CALENDAR_DEFAULT_UPCOMING_MAXEVENTS;
+        if (isset($CFG->calendar_maxevents)) {
+            $defaultmaxevents = intval($CFG->calendar_maxevents);
+        }
+        $maxevents = get_user_preferences('calendar_maxevents', $defaultmaxevents);
         $tstart = $type->convert_to_timestamp($date['year'], $date['mon'], $date['mday'], $date['hours']);
-        $tend = usergetmidnight($tstart + DAYSECS * $defaultlookahead + 3 * HOURSECS) - 1;
+        $tend = usergetmidnight($tstart + DAYSECS * $lookahead + 3 * HOURSECS) - 1;
     } else {
         $tstart = $type->convert_to_timestamp($date['year'], $date['mon'], 1);
         $monthdays = $type->get_num_days_in_month($date['year'], $date['mon']);
@@ -3188,6 +3114,14 @@ function calendar_get_view(\calendar_information $calendar, $view, $includenavig
         return $param;
     }, [$calendar->users, $calendar->groups, $calendar->courses, $calendar->categories]);
 
+    // We need to make sure user calendar preferences are respected.
+    // If max upcoming events is not set then use default value of 40 events.
+    if (isset($maxevents)) {
+        $limit = $maxevents;
+    } else {
+        $limit = 40;
+    }
+
     $events = \core_calendar\local\api::get_events(
         $tstart,
         $tend,
@@ -3195,7 +3129,7 @@ function calendar_get_view(\calendar_information $calendar, $view, $includenavig
         null,
         null,
         null,
-        40,
+        $limit,
         null,
         $userparam,
         $groupparam,
@@ -3234,10 +3168,15 @@ function calendar_get_view(\calendar_information $calendar, $view, $includenavig
         $day = new \core_calendar\external\calendar_day_exporter($calendar, $related);
         $data = $day->export($renderer);
         $template = 'core_calendar/calendar_day';
-    } else if ($view == "upcoming") {
+    } else if ($view == "upcoming" || $view == "upcoming_mini") {
         $upcoming = new \core_calendar\external\calendar_upcoming_exporter($calendar, $related);
         $data = $upcoming->export($renderer);
-        $template = 'core_calendar/calendar_upcoming';
+
+        if ($view == "upcoming") {
+            $template = 'core_calendar/calendar_upcoming';
+        } else if ($view == "upcoming_mini") {
+            $template = 'core_calendar/upcoming_mini';
+        }
     }
 
     return [$data, $template];
@@ -3408,7 +3347,7 @@ function calendar_get_filter_types() {
 
     return array_map(function($type) {
         return [
-            'type' => $type,
+            'eventtype' => $type,
             'name' => get_string("eventtype{$type}", "calendar"),
         ];
     }, $types);
index be1154b..14123d7 100644 (file)
     {
     }
 }}
-<a href="#" data-action="filter-event-type" data-eventtype="{{type}}" data-eventtype-hidden="{{hidden}}">
+<a href="#" data-action="filter-event-type" data-eventtype="{{eventtype}}" data-eventtype-hidden="{{hidden}}">
     {{^hidden}}
-        <span class="calendar_event_{{type}}">
+        <span class="calendar_event_{{eventtype}}">
             {{#pix}}i/hide, core, {{#str}}hideeventtype, calendar, {{name}}{{/str}}{{/pix}}
         </span>
+        {{> core_calendar/event_icon}}
         <span class="eventname">{{#str}}hideeventtype, calendar, {{name}}{{/str}}</span>
     {{/hidden}}
     {{#hidden}}
-        <span class="calendar_event_{{type}}">
+        <span class="calendar_event_{{eventtype}}">
             {{#pix}}i/show, core, {{#str}}showeventtype, calendar, {{name}}{{/str}}{{/pix}}
         </span>
+        {{> core_calendar/event_icon}}
         <span class="eventname">{{#str}}showeventtype, calendar, {{name}}{{/str}}</span>
     {{/hidden}}
+
 </a>
diff --git a/calendar/templates/event_icon.mustache b/calendar/templates/event_icon.mustache
new file mode 100644 (file)
index 0000000..fb88bd6
--- /dev/null
@@ -0,0 +1,41 @@
+{{!
+    This file is part of Moodle - http://moodle.org/
+
+    Moodle is free software: you can redistribute it and/or modify
+    it under the terms of the GNU General Public License as published by
+    the Free Software Foundation, either version 3 of the License, or
+    (at your option) any later version.
+
+    Moodle is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+    GNU General Public License for more details.
+
+    You should have received a copy of the GNU General Public License
+    along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+}}
+{{!
+    @template calendar/event_icon
+
+    Event icon display.
+
+    The purpose of this template is to provide a common location for the
+    rendering of event icons.
+
+    Classes required for JS:
+    * none
+
+    Data attributes required for JS:
+    * none
+
+    Example context (json):
+    {
+        "modulename": "assign"
+    }
+}}
+{{#modulename}}
+    {{#pix}} icon, {{modulename}} {{/pix}}
+{{/modulename}}
+{{^modulename}}
+    {{#pix}} i/{{eventtype}}event, core {{/pix}}
+{{/modulename}}
index d9769b2..427919e 100644 (file)
@@ -22,8 +22,7 @@
     Example context (json):
     {
         "exportcalendarbutton": "<button class='btn btn-secondary'>Export calendar</button>",
-        "managesubscriptionbutton": "<button class='btn btn-secondary'>Manage subscriptions</button>",
-        "icalurl": "http://example.com/"
+        "managesubscriptionbutton": "<button class='btn btn-secondary'>Manage subscriptions</button>"
     }
 }}
 <div class="bottom">
@@ -33,7 +32,4 @@
     {{#managesubscriptionbutton}}
         {{> core/single_button }}
     {{/managesubscriptionbutton}}
-    {{#icalurl}}
-        <a href="{{icalurl}}" title="{{#str}} quickdownloadcalendar, calendar {{/str}}" class="ical-link m-l-1">iCal</a>
-    {{/icalurl}}
 </div>
index 942e459..39cacee 100644 (file)
                                             <li class="events-underway">[{{name}}]</li>
                                         {{/underway}}
                                         {{^underway}}
-                                            <li class="calendar_event_{{calendareventtype}}"
-                                                data-region="event-item"
+                                            <li data-region="event-item"
                                                 data-eventtype-{{calendareventtype}}="1"
                                                 {{#canedit}}
                                                     draggable="true"
                                                     data-drag-type="move"
                                                 {{/canedit}}>
 
-                                                <a data-action="view-event" data-event-id="{{id}}" href="{{url}}">{{name}}</a>
+                                                <a data-action="view-event" data-event-id="{{id}}" href="{{url}}" title="{{name}}">
+                                                    <span class="badge badge-circle calendar_event_{{calendareventtype}}">
+                                                        &nbsp;
+                                                    </span>
+                                                    {{> core_calendar/event_icon}}
+                                                    {{name}}
+                                                </a>
                                             </li>
                                         {{/underway}}
                                         {{/events}}
diff --git a/calendar/templates/upcoming_mini.mustache b/calendar/templates/upcoming_mini.mustache
new file mode 100644 (file)
index 0000000..8d02953
--- /dev/null
@@ -0,0 +1,43 @@
+{{!
+    This file is part of Moodle - http://moodle.org/
+
+    Moodle is free software: you can redistribute it and/or modify
+    it under the terms of the GNU General Public License as published by
+    the Free Software Foundation, either version 3 of the License, or
+    (at your option) any later version.
+
+    Moodle is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+    GNU General Public License for more details.
+
+    You should have received a copy of the GNU General Public License
+    along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+}}
+{{!
+    @template calendar/upcoming_mini
+
+    Calendar upcoming view for blocks.
+
+    The purpose of this template is to render the upcoming view for blocks.
+
+    Classes required for JS:
+    * none
+
+    Data attributes required for JS:
+    * none
+
+    Example context (json):
+    {
+    }
+}}
+<div class="card-text content">
+    {{#events}}
+        <div class="event">
+            <span>{{#icon}}{{#pix}} {{key}}, {{component}}, {{alttext}} {{/pix}}{{/icon}}</span>
+            <a href="{{viewurl}}">{{{name}}}</a>
+            <div class="date">{{{formattedtime}}}</div>
+        </div>
+        <hr>
+    {{/events}}
+</div>
index 300588c..f35f252 100644 (file)
@@ -53,6 +53,7 @@ Feature: Perform basic calendar functionality
     Given I log in as "teacher1"
     And I create a calendar event with form data:
       | Type of event | course |
+      | Course        | Course 1 |
       | Event title | Really awesome event! |
       | Description | Come join this awesome event, sucka! |
     And I log out
@@ -70,8 +71,10 @@ Feature: Perform basic calendar functionality
   @javascript
   Scenario: Create a group event
     Given I log in as "teacher1"
+    And I am on "Course 1" course homepage
     And I create a calendar event with form data:
       | Type of event | group |
+      | Group         | Group 1 |
       | Event title | Really awesome event! |
       | Description | Come join this awesome event |
     And I log out
index 23efe02..1bba607 100644 (file)
@@ -499,7 +499,7 @@ class core_calendar_lib_testcase extends advanced_testcase {
         $types = calendar_get_all_allowed_types();
         $typecourses = $types['course'];
         $this->assertCount(1, $typecourses);
-        $this->assertEquals($course1->id, $typecourses[0]->id);
+        $this->assertEquals($course1->id, $typecourses[$course1->id]->id);
 
         assign_capability('moodle/calendar:manageentries', CAP_ALLOW, $roleid, $context2, true);
 
@@ -543,7 +543,7 @@ class core_calendar_lib_testcase extends advanced_testcase {
         $types = calendar_get_all_allowed_types();
         $typecourses = $types['course'];
         $this->assertCount(1, $typecourses);
-        $this->assertEquals($course->id, $typecourses[0]->id);
+        $this->assertEquals($course->id, $typecourses[$course->id]->id);
         $this->assertArrayNotHasKey('group', $types);
         $this->assertArrayNotHasKey('groupcourses', $types);
     }
@@ -570,7 +570,7 @@ class core_calendar_lib_testcase extends advanced_testcase {
         $types = calendar_get_all_allowed_types();
         $typecourses = $types['course'];
         $this->assertCount(1, $typecourses);
-        $this->assertEquals($course->id, $typecourses[0]->id);
+        $this->assertEquals($course->id, $typecourses[$course->id]->id);
         $this->assertArrayNotHasKey('group', $types);
         $this->assertArrayNotHasKey('groupcourses', $types);
     }
@@ -622,7 +622,7 @@ class core_calendar_lib_testcase extends advanced_testcase {
         $this->assertEquals($course1->id, $typecourses[0]->id);
         $this->assertEquals($course2->id, $typecourses[1]->id);
         $this->assertCount(1, $typegroupcourses);
-        $this->assertEquals($course1->id, $typegroupcourses[0]->id);
+        $this->assertEquals($course1->id, $typegroupcourses[$course1->id]->id);
         $this->assertCount(2, $typegroups);
         $this->assertEquals($group1->id, $typegroups[0]->id);
         $this->assertEquals($group2->id, $typegroups[1]->id);
@@ -667,7 +667,7 @@ class core_calendar_lib_testcase extends advanced_testcase {
         usort($typegroups, $idascfunc);
 
         $this->assertCount(1, $typegroupcourses);
-        $this->assertEquals($course->id, $typegroupcourses[0]->id);
+        $this->assertEquals($course->id, $typegroupcourses[$course->id]->id);
         $this->assertCount(2, $typegroups);
         $this->assertEquals($group1->id, $typegroups[0]->id);
         $this->assertEquals($group2->id, $typegroups[1]->id);
index 7979e61..142496b 100644 (file)
@@ -114,6 +114,86 @@ class core_completion_external extends external_api {
         );
     }
 
+    /**
+     * Describes the parameters for override_activity_completion_status.
+     *
+     * @return external_external_function_parameters
+     * @since Moodle 3.4
+     */
+    public static function override_activity_completion_status_parameters() {
+        return new external_function_parameters (
+            array(
+                'userid' => new external_value(PARAM_INT, 'user id'),
+                'cmid' => new external_value(PARAM_INT, 'course module id'),
+                'newstate' => new external_value(PARAM_INT, 'the new activity completion state'),
+            )
+        );
+    }
+
+    /**
+     * Update completion status for a user in an activity.
+     * @param  int $userid    User id
+     * @param  int $cmid      Course module id
+     * @param  int $newstate  Activity completion
+     * @return array          Array containing the current (updated) completion status.
+     * @since Moodle 3.4
+     * @throws moodle_exception
+     */
+    public static function override_activity_completion_status($userid, $cmid, $newstate) {
+        // Validate and normalize parameters.
+        $params = self::validate_parameters(self::override_activity_completion_status_parameters(),
+            array('userid' => $userid, 'cmid' => $cmid, 'newstate' => $newstate));
+        $userid = $params['userid'];
+        $cmid = $params['cmid'];
+        $newstate = $params['newstate'];
+
+        $context = context_module::instance($cmid);
+        self::validate_context($context);
+
+        list($course, $cm) = get_course_and_cm_from_cmid($cmid);
+
+        // Set up completion object and check it is enabled.
+        $completion = new completion_info($course);
+        if (!$completion->is_enabled()) {
+            throw new moodle_exception('completionnotenabled', 'completion');
+        }
+
+        // Update completion state and get the new state back.
+        $completion->update_state($cm, $newstate, $userid, true);
+        $completiondata = $completion->get_data($cm, false, $userid);
+
+        // Return the current state of completion.
+        return [
+            'cmid' => $completiondata->coursemoduleid,
+            'userid' => $completiondata->userid,
+            'state' => $completiondata->completionstate,
+            'timecompleted' => $completiondata->timemodified,
+            'overrideby' => $completiondata->overrideby,
+            'tracking' => $completion->is_enabled($cm)
+        ];
+    }
+
+    /**
+     * Describes the override_activity_completion_status return value.
+     *
+     * @return external_single_structure
+     * @since Moodle 3.4
+     */
+    public static function override_activity_completion_status_returns() {
+
+        return new external_single_structure(
+            array(
+                'cmid' => new external_value(PARAM_INT, 'The course module id'),
+                'userid' => new external_value(PARAM_INT, 'The user id to which the completion info belongs'),
+                'state'   => new external_value(PARAM_INT, 'The current completion state.'),
+                'timecompleted' => new external_value(PARAM_INT, 'time of completion'),
+                'overrideby' => new external_value(PARAM_INT, 'The user id who has overriden the status, or null'),
+                'tracking'      => new external_value(PARAM_INT, 'type of tracking:
+                                                                    0 means none, 1 manual, 2 automatic'),
+            )
+        );
+    }
+
     /**
      * Returns description of method parameters
      *
index 61ef537..0a5ec14 100644 (file)
@@ -186,6 +186,83 @@ class core_completion_externallib_testcase extends externallib_advanced_testcase
         $this->assertCount(3, $result['statuses']);
     }
 
+    /**
+     * Test override_activity_completion_status
+     */
+    public function test_override_activity_completion_status() {
+        global $DB, $CFG;
+        $this->resetAfterTest(true);
+
+        // Create course with teacher and student enrolled.
+        $CFG->enablecompletion = true;
+        $course  = $this->getDataGenerator()->create_course(['enablecompletion' => 1]);
+        $student = $this->getDataGenerator()->create_user();
+        $teacher = $this->getDataGenerator()->create_user();
+        $studentrole = $DB->get_record('role', array('shortname' => 'student'));
+        $this->getDataGenerator()->enrol_user($student->id, $course->id, $studentrole->id);
+        $teacherrole = $DB->get_record('role', array('shortname' => 'teacher'));
+        $this->getDataGenerator()->enrol_user($teacher->id, $course->id, $teacherrole->id);
+        $coursecontext = context_course::instance($course->id);
+
+        // Create 2 activities, one with manual completion (data), one with automatic completion triggered by viewiung it (forum).
+        $data    = $this->getDataGenerator()->create_module('data', ['course' => $course->id], ['completion' => 1]);
+        $forum   = $this->getDataGenerator()->create_module('forum',  ['course' => $course->id],
+                                                            ['completion' => 2, 'completionview' => 1]);
+        $cmdata = get_coursemodule_from_id('data', $data->cmid);
+        $cmforum = get_coursemodule_from_id('forum', $forum->cmid);
+
+        // Manually complete the data activity as the student.
+        $this->setUser($student);
+        $completion = new completion_info($course);
+        $completion->update_state($cmdata, COMPLETION_COMPLETE);
+
+        // Test overriding the status of the manual-completion-activity 'incomplete'.
+        $this->setUser($teacher);
+        $result = core_completion_external::override_activity_completion_status($student->id, $data->cmid, COMPLETION_INCOMPLETE);
+        $result = external_api::clean_returnvalue(core_completion_external::override_activity_completion_status_returns(), $result);
+        $this->assertEquals($result['state'], COMPLETION_INCOMPLETE);
+        $completiondata = $completion->get_data($cmdata, false, $student->id);
+        $this->assertEquals(COMPLETION_INCOMPLETE, $completiondata->completionstate);
+
+        // Test overriding the status of the manual-completion-activity back to 'complete'.
+        $result = core_completion_external::override_activity_completion_status($student->id, $data->cmid, COMPLETION_COMPLETE);
+        $result = external_api::clean_returnvalue(core_completion_external::override_activity_completion_status_returns(), $result);
+        $this->assertEquals($result['state'], COMPLETION_COMPLETE);
+        $completiondata = $completion->get_data($cmdata, false, $student->id);
+        $this->assertEquals(COMPLETION_COMPLETE, $completiondata->completionstate);
+
+        // Test overriding the status of the auto-completion-activity to 'complete'.
+        $result = core_completion_external::override_activity_completion_status($student->id, $forum->cmid, COMPLETION_COMPLETE);
+        $result = external_api::clean_returnvalue(core_completion_external::override_activity_completion_status_returns(), $result);
+        $this->assertEquals($result['state'], COMPLETION_COMPLETE);
+        $completionforum = $completion->get_data($cmforum, false, $student->id);
+        $this->assertEquals(COMPLETION_COMPLETE, $completionforum->completionstate);
+
+        // Test overriding the status of the auto-completion-activity to 'incomplete'.
+        $result = core_completion_external::override_activity_completion_status($student->id, $forum->cmid, COMPLETION_INCOMPLETE);
+        $result = external_api::clean_returnvalue(core_completion_external::override_activity_completion_status_returns(), $result);
+        $this->assertEquals($result['state'], COMPLETION_INCOMPLETE);
+        $completionforum = $completion->get_data($cmforum, false, $student->id);
+        $this->assertEquals(COMPLETION_INCOMPLETE, $completionforum->completionstate);
+
+        // Test overriding the status of the auto-completion-activity to an invalid state. It should remain incomplete.
+        $this->expectException('moodle_exception');
+        $result = core_completion_external::override_activity_completion_status($student->id, $forum->cmid, 3);
+        $result = external_api::clean_returnvalue(core_completion_external::override_activity_completion_status_returns(), $result);
+        $this->assertEquals($result['state'], COMPLETION_INCOMPLETE);
+        $completionforum = $completion->get_data($cmforum, false, $student->id);
+        $this->assertEquals(COMPLETION_INCOMPLETE, $completionforum->completionstate);
+
+        // Test overriding the status of the auto-completion-activity for a user without capabilities. It should remain incomplete.
+        $this->expectException('moodle_exception');
+        unassign_capability('moodle/course:overridecompletion', $teacherrole->id, $coursecontext);
+        $result = core_completion_external::override_activity_completion_status($student->id, $forum->cmid, 1);
+        $result = external_api::clean_returnvalue(core_completion_external::override_activity_completion_status_returns(), $result);
+        $this->assertEquals($result['state'], COMPLETION_INCOMPLETE);
+        $completionforum = $completion->get_data($cmforum, false, $student->id);
+        $this->assertEquals(COMPLETION_INCOMPLETE, $completionforum->completionstate);
+    }
+
     /**
      * Test get_course_completion_status
      */
index dcb07cf..a656ec1 100644 (file)
@@ -7,7 +7,7 @@
     "require-dev": {
         "phpunit/phpunit": "5.5.*",
         "phpunit/dbUnit": "1.4.*",
-        "moodlehq/behat-extension": "3.34.0",
+        "moodlehq/behat-extension": "3.34.1",
         "mikey179/vfsStream": "^1.6"
     }
 }
index cb64acd..1150efe 100644 (file)
@@ -4,8 +4,7 @@
         "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#composer-lock-the-lock-file",
         "This file is @generated automatically"
     ],
-    "hash": "87cf286828dd74f76aa6021b4cf7ecd5",
-    "content-hash": "ce905d6cf20a164ed747648b85732e8d",
+    "content-hash": "1906bd3ac810927fb8084fe4e2967d36",
     "packages": [],
     "packages-dev": [
         {
                 "symfony",
                 "testing"
             ],
-            "time": "2017-05-15 16:49:16"
+            "time": "2017-05-15T16:49:16+00:00"
         },
         {
             "name": "behat/gherkin",
-            "version": "v4.4.5",
+            "version": "v4.5.1",
             "source": {
                 "type": "git",
                 "url": "https://github.com/Behat/Gherkin.git",
-                "reference": "5c14cff4f955b17d20d088dec1bde61c0539ec74"
+                "reference": "74ac03d52c5e23ad8abd5c5cce4ab0e8dc1b530a"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/Behat/Gherkin/zipball/5c14cff4f955b17d20d088dec1bde61c0539ec74",
-                "reference": "5c14cff4f955b17d20d088dec1bde61c0539ec74",
+                "url": "https://api.github.com/repos/Behat/Gherkin/zipball/74ac03d52c5e23ad8abd5c5cce4ab0e8dc1b530a",
+                "reference": "74ac03d52c5e23ad8abd5c5cce4ab0e8dc1b530a",
                 "shasum": ""
             },
             "require": {
                 "gherkin",
                 "parser"
             ],
-            "time": "2016-10-30 11:50:56"
+            "time": "2017-08-30T11:04:43+00:00"
         },
         {
             "name": "behat/mink",
                 "testing",
                 "web"
             ],
-            "time": "2016-03-05 08:26:18"
+            "time": "2016-03-05T08:26:18+00:00"
         },
         {
             "name": "behat/mink-browserkit-driver",
                 "browser",
                 "testing"
             ],
-            "time": "2016-03-05 08:59:47"
+            "time": "2016-03-05T08:59:47+00:00"
         },
         {
             "name": "behat/mink-extension",
                 "test",
                 "web"
             ],
-            "time": "2016-02-15 07:55:18"
+            "time": "2016-02-15T07:55:18+00:00"
         },
         {
             "name": "behat/mink-goutte-driver",
                 "headless",
                 "testing"
             ],
-            "time": "2016-03-05 09:04:22"
+            "time": "2016-03-05T09:04:22+00:00"
         },
         {
             "name": "behat/mink-selenium2-driver",
                 "testing",
                 "webdriver"
             ],
-            "time": "2016-03-05 09:10:18"
+            "time": "2016-03-05T09:10:18+00:00"
         },
         {
             "name": "behat/transliterator",
                 "slug",
                 "transliterator"
             ],
-            "time": "2017-04-04 11:38:05"
+            "time": "2017-04-04T11:38:05+00:00"
         },
         {
             "name": "container-interop/container-interop",
             ],
             "description": "Promoting the interoperability of container objects (DIC, SL, etc.)",
             "homepage": "https://github.com/container-interop/container-interop",
-            "time": "2017-02-14 19:40:03"
+            "time": "2017-02-14T19:40:03+00:00"
         },
         {
             "name": "doctrine/instantiator",
                 "constructor",
                 "instantiate"
             ],
-            "time": "2015-06-14 21:17:01"
+            "time": "2015-06-14T21:17:01+00:00"
         },
         {
             "name": "fabpot/goutte",
             "keywords": [
                 "scraper"
             ],
-            "time": "2017-01-03 13:21:43"
+            "time": "2017-01-03T13:21:43+00:00"
         },
         {
             "name": "guzzlehttp/guzzle",
-            "version": "6.2.3",
+            "version": "6.3.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/guzzle/guzzle.git",
-                "reference": "8d6c6cc55186db87b7dc5009827429ba4e9dc006"
+                "reference": "f4db5a78a5ea468d4831de7f0bf9d9415e348699"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/guzzle/guzzle/zipball/8d6c6cc55186db87b7dc5009827429ba4e9dc006",
-                "reference": "8d6c6cc55186db87b7dc5009827429ba4e9dc006",
+                "url": "https://api.github.com/repos/guzzle/guzzle/zipball/f4db5a78a5ea468d4831de7f0bf9d9415e348699",
+                "reference": "f4db5a78a5ea468d4831de7f0bf9d9415e348699",
                 "shasum": ""
             },
             "require": {
             },
             "require-dev": {
                 "ext-curl": "*",
-                "phpunit/phpunit": "^4.0",
+                "phpunit/phpunit": "^4.0 || ^5.0",
                 "psr/log": "^1.0"
             },
+            "suggest": {
+                "psr/log": "Required for using the Log middleware"
+            },
             "type": "library",
             "extra": {
                 "branch-alias": {
                 "rest",
                 "web service"
             ],
-            "time": "2017-02-28 22:50:30"
+            "time": "2017-06-22T18:50:49+00:00"
         },
         {
             "name": "guzzlehttp/promises",
             "keywords": [
                 "promise"
             ],
-            "time": "2016-12-20 10:07:11"
+            "time": "2016-12-20T10:07:11+00:00"
         },
         {
             "name": "guzzlehttp/psr7",
                 "uri",
                 "url"
             ],
-            "time": "2017-03-20 17:10:46"
+            "time": "2017-03-20T17:10:46+00:00"
         },
         {
             "name": "instaclick/php-webdriver",
-            "version": "1.4.3",
+            "version": "1.4.5",
             "source": {
                 "type": "git",
                 "url": "https://github.com/instaclick/php-webdriver.git",
-                "reference": "0c20707dcf30a32728fd6bdeeab996c887fdb2fb"
+                "reference": "6fa959452e774dcaed543faad3a9d1a37d803327"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/instaclick/php-webdriver/zipball/0c20707dcf30a32728fd6bdeeab996c887fdb2fb",
-                "reference": "0c20707dcf30a32728fd6bdeeab996c887fdb2fb",
+                "url": "https://api.github.com/repos/instaclick/php-webdriver/zipball/6fa959452e774dcaed543faad3a9d1a37d803327",
+                "reference": "6fa959452e774dcaed543faad3a9d1a37d803327",
                 "shasum": ""
             },
             "require": {
                 "php": ">=5.3.2"
             },
             "require-dev": {
-                "satooshi/php-coveralls": "dev-master"
+                "phpunit/phpunit": "^4.8",
+                "satooshi/php-coveralls": "^1.0||^2.0"
             },
             "type": "library",
             "extra": {
                 {
                     "name": "Anthon Pang",
                     "email": "apang@softwaredevelopment.ca",
-                    "role": "Fork maintainer"
+                    "role": "Fork Maintainer"
                 }
             ],
             "description": "PHP WebDriver for Selenium 2",
                 "webdriver",
                 "webtest"
             ],
-            "time": "2015-06-15 20:19:33"
+            "time": "2017-06-30T04:02:48+00:00"
         },
         {
             "name": "mikey179/vfsStream",
-            "version": "v1.6.4",
+            "version": "v1.6.5",
             "source": {
                 "type": "git",
                 "url": "https://github.com/mikey179/vfsStream.git",
-                "reference": "0247f57b2245e8ad2e689d7cee754b45fbabd592"
+                "reference": "d5fec95f541d4d71c4823bb5e30cf9b9e5b96145"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/mikey179/vfsStream/zipball/0247f57b2245e8ad2e689d7cee754b45fbabd592",
-                "reference": "0247f57b2245e8ad2e689d7cee754b45fbabd592",
+                "url": "https://api.github.com/repos/mikey179/vfsStream/zipball/d5fec95f541d4d71c4823bb5e30cf9b9e5b96145",
+                "reference": "d5fec95f541d4d71c4823bb5e30cf9b9e5b96145",
                 "shasum": ""
             },
             "require": {
             ],
             "description": "Virtual file system to mock the real file system in unit tests.",
             "homepage": "http://vfs.bovigo.org/",
-            "time": "2016-07-18 14:02:57"
+            "time": "2017-08-01T08:02:14+00:00"
         },
         {
             "name": "moodlehq/behat-extension",
-            "version": "v3.34.0",
+            "version": "v3.34.1",
             "source": {
                 "type": "git",
                 "url": "https://github.com/moodlehq/moodle-behat-extension.git",
-                "reference": "a1f956fb13ef4c430ceb37c6c1ffcd355d956a22"
+                "reference": "8d0c4248b1efe6bc141fc7dc17d16fed1df017a5"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/moodlehq/moodle-behat-extension/zipball/a1f956fb13ef4c430ceb37c6c1ffcd355d956a22",
-                "reference": "a1f956fb13ef4c430ceb37c6c1ffcd355d956a22",
+                "url": "https://api.github.com/repos/moodlehq/moodle-behat-extension/zipball/8d0c4248b1efe6bc141fc7dc17d16fed1df017a5",
+                "reference": "8d0c4248b1efe6bc141fc7dc17d16fed1df017a5",
                 "shasum": ""
             },
             "require": {
                 "behat/mink-extension": "~2.2",
                 "behat/mink-goutte-driver": "~1.2",
                 "behat/mink-selenium2-driver": "~1.3",
+                "guzzlehttp/guzzle": "^6.3",
                 "php": ">=5.4.4",
                 "symfony/process": "2.8.*"
             },
                 "Behat",
                 "moodle"
             ],
-            "time": "2017-01-20 02:48:22"
+            "time": "2017-09-29T18:10:58+00:00"
         },
         {
             "name": "myclabs/deep-copy",
                 "object",
                 "object graph"
             ],
-            "time": "2017-04-12 18:52:22"
+            "time": "2017-04-12T18:52:22+00:00"
         },
         {
             "name": "phpdocumentor/reflection-common",
-            "version": "1.0",
+            "version": "1.0.1",
             "source": {
                 "type": "git",
                 "url": "https://github.com/phpDocumentor/ReflectionCommon.git",
-                "reference": "144c307535e82c8fdcaacbcfc1d6d8eeb896687c"
+                "reference": "21bdeb5f65d7ebf9f43b1b25d404f87deab5bfb6"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/phpDocumentor/ReflectionCommon/zipball/144c307535e82c8fdcaacbcfc1d6d8eeb896687c",
-                "reference": "144c307535e82c8fdcaacbcfc1d6d8eeb896687c",
+                "url": "https://api.github.com/repos/phpDocumentor/ReflectionCommon/zipball/21bdeb5f65d7ebf9f43b1b25d404f87deab5bfb6",
+                "reference": "21bdeb5f65d7ebf9f43b1b25d404f87deab5bfb6",
                 "shasum": ""
             },
             "require": {
                 "reflection",
                 "static analysis"
             ],
-            "time": "2015-12-27 11:43:31"
+            "time": "2017-09-11T18:02:19+00:00"
         },
         {
             "name": "phpdocumentor/reflection-docblock",
-            "version": "3.1.1",
+            "version": "4.1.1",
             "source": {
                 "type": "git",
                 "url": "https://github.com/phpDocumentor/ReflectionDocBlock.git",
-                "reference": "8331b5efe816ae05461b7ca1e721c01b46bafb3e"
+                "reference": "2d3d238c433cf69caeb4842e97a3223a116f94b2"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/8331b5efe816ae05461b7ca1e721c01b46bafb3e",
-                "reference": "8331b5efe816ae05461b7ca1e721c01b46bafb3e",
+                "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/2d3d238c433cf69caeb4842e97a3223a116f94b2",
+                "reference": "2d3d238c433cf69caeb4842e97a3223a116f94b2",
                 "shasum": ""
             },
             "require": {
-                "php": ">=5.5",
+                "php": "^7.0",
                 "phpdocumentor/reflection-common": "^1.0@dev",
-                "phpdocumentor/type-resolver": "^0.2.0",
+                "phpdocumentor/type-resolver": "^0.4.0",
                 "webmozart/assert": "^1.0"
             },
             "require-dev": {
                 }
             ],
             "description": "With this component, a library can provide support for annotations via DocBlocks or otherwise retrieve information that is embedded in a DocBlock.",
-            "time": "2016-09-30 07:12:33"
+            "time": "2017-08-30T18:51:59+00:00"
         },
         {
             "name": "phpdocumentor/type-resolver",
-            "version": "0.2.1",
+            "version": "0.4.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/phpDocumentor/TypeResolver.git",
-                "reference": "e224fb2ea2fba6d3ad6fdaef91cd09a172155ccb"
+                "reference": "9c977708995954784726e25d0cd1dddf4e65b0f7"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/e224fb2ea2fba6d3ad6fdaef91cd09a172155ccb",
-                "reference": "e224fb2ea2fba6d3ad6fdaef91cd09a172155ccb",
+                "url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/9c977708995954784726e25d0cd1dddf4e65b0f7",
+                "reference": "9c977708995954784726e25d0cd1dddf4e65b0f7",
                 "shasum": ""
             },
             "require": {
-                "php": ">=5.5",
+                "php": "^5.5 || ^7.0",
                 "phpdocumentor/reflection-common": "^1.0"
             },
             "require-dev": {
                     "email": "me@mikevanriel.com"
                 }
             ],
-            "time": "2016-11-25 06:54:22"
+            "time": "2017-07-14T14:27:02+00:00"
         },
         {
             "name": "phpspec/prophecy",
-            "version": "v1.7.0",
+            "version": "v1.7.2",
             "source": {
                 "type": "git",
                 "url": "https://github.com/phpspec/prophecy.git",
-                "reference": "93d39f1f7f9326d746203c7c056f300f7f126073"
+                "reference": "c9b8c6088acd19d769d4cc0ffa60a9fe34344bd6"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/phpspec/prophecy/zipball/93d39f1f7f9326d746203c7c056f300f7f126073",
-                "reference": "93d39f1f7f9326d746203c7c056f300f7f126073",
+                "url": "https://api.github.com/repos/phpspec/prophecy/zipball/c9b8c6088acd19d769d4cc0ffa60a9fe34344bd6",
+                "reference": "c9b8c6088acd19d769d4cc0ffa60a9fe34344bd6",
                 "shasum": ""
             },
             "require": {
                 "doctrine/instantiator": "^1.0.2",
                 "php": "^5.3|^7.0",
-                "phpdocumentor/reflection-docblock": "^2.0|^3.0.2",
+                "phpdocumentor/reflection-docblock": "^2.0|^3.0.2|^4.0",
                 "sebastian/comparator": "^1.1|^2.0",
                 "sebastian/recursion-context": "^1.0|^2.0|^3.0"
             },
             "type": "library",
             "extra": {
                 "branch-alias": {
-                    "dev-master": "1.6.x-dev"
+                    "dev-master": "1.7.x-dev"
                 }
             },
             "autoload": {
                 "spy",
                 "stub"
             ],
-            "time": "2017-03-02 20:05:34"
+            "time": "2017-09-04T11:05:03+00:00"
         },
         {
             "name": "phpunit/dbunit",
                 "testing",
                 "xunit"
             ],
-            "time": "2015-08-07 04:57:38"
+            "time": "2015-08-07T04:57:38+00:00"
         },
         {
             "name": "phpunit/php-code-coverage",
                 "testing",
                 "xunit"
             ],
-            "time": "2017-04-02 07:44:40"
+            "time": "2017-04-02T07:44:40+00:00"
         },
         {
             "name": "phpunit/php-file-iterator",
                 "filesystem",
                 "iterator"
             ],
-            "time": "2016-10-03 07:40:28"
+            "time": "2016-10-03T07:40:28+00:00"
         },
         {
             "name": "phpunit/php-text-template",
             "keywords": [
                 "template"
             ],
-            "time": "2015-06-21 13:50:34"
+            "time": "2015-06-21T13:50:34+00:00"
         },
         {
             "name": "phpunit/php-timer",
             "keywords": [
                 "timer"
             ],
-            "time": "2017-02-26 11:10:40"
+            "time": "2017-02-26T11:10:40+00:00"
         },
         {
             "name": "phpunit/php-token-stream",
-            "version": "1.4.11",
+            "version": "2.0.1",
             "source": {
                 "type": "git",
                 "url": "https://github.com/sebastianbergmann/php-token-stream.git",
-                "reference": "e03f8f67534427a787e21a385a67ec3ca6978ea7"
+                "reference": "9a02332089ac48e704c70f6cefed30c224e3c0b0"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/sebastianbergmann/php-token-stream/zipball/e03f8f67534427a787e21a385a67ec3ca6978ea7",
-                "reference": "e03f8f67534427a787e21a385a67ec3ca6978ea7",
+                "url": "https://api.github.com/repos/sebastianbergmann/php-token-stream/zipball/9a02332089ac48e704c70f6cefed30c224e3c0b0",
+                "reference": "9a02332089ac48e704c70f6cefed30c224e3c0b0",
                 "shasum": ""
             },
             "require": {
                 "ext-tokenizer": "*",
-                "php": ">=5.3.3"
+                "php": "^7.0"
             },
             "require-dev": {
-                "phpunit/phpunit": "~4.2"
+                "phpunit/phpunit": "^6.2.4"
             },
             "type": "library",
             "extra": {
                 "branch-alias": {
-                    "dev-master": "1.4-dev"
+                    "dev-master": "2.0-dev"
                 }
             },
             "autoload": {
             "keywords": [
                 "tokenizer"
             ],
-            "time": "2017-02-27 10:12:30"
+            "time": "2017-08-20T05:47:52+00:00"
         },
         {
             "name": "phpunit/phpunit",
                 "testing",
                 "xunit"
             ],
-            "time": "2016-10-03 13:04:15"
+            "time": "2016-10-03T13:04:15+00:00"
         },
         {
             "name": "phpunit/phpunit-mock-objects",
-            "version": "3.4.3",
+            "version": "3.4.4",
             "source": {
                 "type": "git",
                 "url": "https://github.com/sebastianbergmann/phpunit-mock-objects.git",
-                "reference": "3ab72b65b39b491e0c011e2e09bb2206c2aa8e24"
+                "reference": "a23b761686d50a560cc56233b9ecf49597cc9118"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/sebastianbergmann/phpunit-mock-objects/zipball/3ab72b65b39b491e0c011e2e09bb2206c2aa8e24",
-                "reference": "3ab72b65b39b491e0c011e2e09bb2206c2aa8e24",
+                "url": "https://api.github.com/repos/sebastianbergmann/phpunit-mock-objects/zipball/a23b761686d50a560cc56233b9ecf49597cc9118",
+                "reference": "a23b761686d50a560cc56233b9ecf49597cc9118",
                 "shasum": ""
             },
             "require": {
                 "mock",
                 "xunit"
             ],
-            "time": "2016-12-08 20:27:08"
+            "time": "2017-06-30T09:13:00+00:00"
         },
         {
             "name": "psr/container",
                 "container-interop",
                 "psr"
             ],
-            "time": "2017-02-14 16:28:37"
+            "time": "2017-02-14T16:28:37+00:00"
         },
         {
             "name": "psr/http-message",
                 "request",
                 "response"
             ],
-            "time": "2016-08-06 14:39:51"
+            "time": "2016-08-06T14:39:51+00:00"
         },
         {
             "name": "psr/log",
                 "psr",
                 "psr-3"
             ],
-            "time": "2016-10-10 12:19:37"
+            "time": "2016-10-10T12:19:37+00:00"
         },
         {
             "name": "sebastian/code-unit-reverse-lookup",
             ],
             "description": "Looks up which function or method a line of code belongs to",
             "homepage": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/",
-            "time": "2017-03-04 06:30:41"
+            "time": "2017-03-04T06:30:41+00:00"
         },
         {
             "name": "sebastian/comparator",
                 "compare",
                 "equality"
             ],
-            "time": "2017-01-29 09:50:25"
+            "time": "2017-01-29T09:50:25+00:00"
         },
         {
             "name": "sebastian/diff",
             "keywords": [
                 "diff"
             ],
-            "time": "2017-05-22 07:24:03"
+            "time": "2017-05-22T07:24:03+00:00"
         },
         {
             "name": "sebastian/environment",
                 "environment",
                 "hhvm"
             ],
-            "time": "2016-11-26 07:53:53"
+            "time": "2016-11-26T07:53:53+00:00"
         },
         {
             "name": "sebastian/exporter",
                 "export",
                 "exporter"
             ],
-            "time": "2016-06-17 09:04:28"
+            "time": "2016-06-17T09:04:28+00:00"
         },
         {
             "name": "sebastian/global-state",
             "keywords": [
                 "global state"
             ],
-            "time": "2015-10-12 03:26:01"
+            "time": "2015-10-12T03:26:01+00:00"
         },
         {
             "name": "sebastian/object-enumerator",
             ],
             "description": "Traverses array structures and object graphs to enumerate all referenced objects",
             "homepage": "https://github.com/sebastianbergmann/object-enumerator/",
-            "time": "2016-01-28 13:25:10"
+            "time": "2016-01-28T13:25:10+00:00"
         },
         {
             "name": "sebastian/recursion-context",
             ],
             "description": "Provides functionality to recursively process PHP variables",
             "homepage": "http://www.github.com/sebastianbergmann/recursion-context",
-            "time": "2016-10-03 07:41:43"
+            "time": "2016-10-03T07:41:43+00:00"
         },
         {
             "name": "sebastian/resource-operations",
             ],
             "description": "Provides a list of PHP built-in functions that operate on resources",
             "homepage": "https://www.github.com/sebastianbergmann/resource-operations",
-            "time": "2015-07-28 20:34:47"
+            "time": "2015-07-28T20:34:47+00:00"
         },
         {
             "name": "sebastian/version",
             ],
             "description": "Library that helps with managing the version number of Git-hosted PHP projects",
             "homepage": "https://github.com/sebastianbergmann/version",
-            "time": "2016-10-03 07:35:21"
+            "time": "2016-10-03T07:35:21+00:00"
         },
         {
             "name": "symfony/browser-kit",
-            "version": "v3.3.0",
+            "version": "v3.3.9",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/browser-kit.git",
-                "reference": "c2c8ceb1aa9dab9eae54e9150e6a588ce3e53be1"
+                "reference": "aee7120b058c268363e606ff5fe8271da849a1b5"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/browser-kit/zipball/c2c8ceb1aa9dab9eae54e9150e6a588ce3e53be1",
-                "reference": "c2c8ceb1aa9dab9eae54e9150e6a588ce3e53be1",
+                "url": "https://api.github.com/repos/symfony/browser-kit/zipball/aee7120b058c268363e606ff5fe8271da849a1b5",
+                "reference": "aee7120b058c268363e606ff5fe8271da849a1b5",
                 "shasum": ""
             },
             "require": {
-                "php": ">=5.5.9",
+                "php": "^5.5.9|>=7.0.8",
                 "symfony/dom-crawler": "~2.8|~3.0"
             },
             "require-dev": {
             ],
             "description": "Symfony BrowserKit Component",
             "homepage": "https://symfony.com",
-            "time": "2017-04-12 14:14:56"
+            "time": "2017-07-29T21:54:42+00:00"
         },
         {
             "name": "symfony/class-loader",
-            "version": "v3.3.0",
+            "version": "v3.3.9",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/class-loader.git",
-                "reference": "b0aff75bf18e4bbf37209235227e6e50a5aec8f5"
+                "reference": "9c69968ce57924e9e93550895cd2b0477edf0e19"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/class-loader/zipball/b0aff75bf18e4bbf37209235227e6e50a5aec8f5",
-                "reference": "b0aff75bf18e4bbf37209235227e6e50a5aec8f5",
+                "url": "https://api.github.com/repos/symfony/class-loader/zipball/9c69968ce57924e9e93550895cd2b0477edf0e19",
+                "reference": "9c69968ce57924e9e93550895cd2b0477edf0e19",
                 "shasum": ""
             },
             "require": {
-                "php": ">=5.5.9"
+                "php": "^5.5.9|>=7.0.8"
             },
             "require-dev": {
                 "symfony/finder": "~2.8|~3.0",
             ],
             "description": "Symfony ClassLoader Component",
             "homepage": "https://symfony.com",
-            "time": "2017-04-12 14:14:56"
+            "time": "2017-07-29T21:54:42+00:00"
         },
         {
             "name": "symfony/config",
-            "version": "v3.3.0",
+            "version": "v3.3.9",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/config.git",
-                "reference": "79f86253ba482ca7f17718e886e6d164e5ba6d45"
+                "reference": "f9f19a39ee178f61bb2190f51ff7c517c2159315"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/config/zipball/79f86253ba482ca7f17718e886e6d164e5ba6d45",
-                "reference": "79f86253ba482ca7f17718e886e6d164e5ba6d45",
+                "url": "https://api.github.com/repos/symfony/config/zipball/f9f19a39ee178f61bb2190f51ff7c517c2159315",
+                "reference": "f9f19a39ee178f61bb2190f51ff7c517c2159315",
                 "shasum": ""
             },
             "require": {
-                "php": ">=5.5.9",
+                "php": "^5.5.9|>=7.0.8",
                 "symfony/filesystem": "~2.8|~3.0"
             },
             "conflict": {
-                "symfony/dependency-injection": "<3.3"
+                "symfony/dependency-injection": "<3.3",
+                "symfony/finder": "<3.3"
             },
             "require-dev": {
                 "symfony/dependency-injection": "~3.3",
+                "symfony/finder": "~3.3",
                 "symfony/yaml": "~3.0"
             },
             "suggest": {
             ],
             "description": "Symfony Config Component",
             "homepage": "https://symfony.com",
-            "time": "2017-05-29 18:41:32"
+            "time": "2017-09-04T16:28:07+00:00"
         },
         {
             "name": "symfony/console",
-            "version": "v3.3.0",
+            "version": "v3.3.9",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/console.git",
-                "reference": "c80e63f3f5e3a331bfc25e6e9332b10422eb9b05"
+                "reference": "a1e1b01293a090cb9ae2ddd221a3251a4a7e4abf"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/console/zipball/c80e63f3f5e3a331bfc25e6e9332b10422eb9b05",
-                "reference": "c80e63f3f5e3a331bfc25e6e9332b10422eb9b05",
+                "url": "https://api.github.com/repos/symfony/console/zipball/a1e1b01293a090cb9ae2ddd221a3251a4a7e4abf",
+                "reference": "a1e1b01293a090cb9ae2ddd221a3251a4a7e4abf",
                 "shasum": ""
             },
             "require": {
-                "php": ">=5.5.9",
+                "php": "^5.5.9|>=7.0.8",
                 "symfony/debug": "~2.8|~3.0",
                 "symfony/polyfill-mbstring": "~1.0"
             },
             },
             "require-dev": {
                 "psr/log": "~1.0",
+                "symfony/config": "~3.3",
                 "symfony/dependency-injection": "~3.3",
                 "symfony/event-dispatcher": "~2.8|~3.0",
                 "symfony/filesystem": "~2.8|~3.0",
-                "symfony/http-kernel": "~2.8|~3.0",
                 "symfony/process": "~2.8|~3.0"
             },
             "suggest": {
             ],
             "description": "Symfony Console Component",
             "homepage": "https://symfony.com",
-            "time": "2017-05-28 14:08:56"
+            "time": "2017-09-06T16:40:18+00:00"
         },
         {
             "name": "symfony/css-selector",
-            "version": "v3.3.0",
+            "version": "v3.3.9",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/css-selector.git",
-                "reference": "4d882dced7b995d5274293039370148e291808f2"
+                "reference": "c5f5263ed231f164c58368efbce959137c7d9488"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/css-selector/zipball/4d882dced7b995d5274293039370148e291808f2",
-                "reference": "4d882dced7b995d5274293039370148e291808f2",
+                "url": "https://api.github.com/repos/symfony/css-selector/zipball/c5f5263ed231f164c58368efbce959137c7d9488",
+                "reference": "c5f5263ed231f164c58368efbce959137c7d9488",
                 "shasum": ""
             },
             "require": {
-                "php": ">=5.5.9"
+                "php": "^5.5.9|>=7.0.8"
             },
             "type": "library",
             "extra": {
             ],
             "description": "Symfony CssSelector Component",
             "homepage": "https://symfony.com",
-            "time": "2017-05-01 15:01:29"
+            "time": "2017-07-29T21:54:42+00:00"
         },
         {
             "name": "symfony/debug",
-            "version": "v3.3.0",
+            "version": "v3.3.9",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/debug.git",
-                "reference": "ef5f19a7a68075a0bd05969a329ead3b0776fb7a"
+                "reference": "8beb24eec70b345c313640962df933499373a944"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/debug/zipball/ef5f19a7a68075a0bd05969a329ead3b0776fb7a",
-                "reference": "ef5f19a7a68075a0bd05969a329ead3b0776fb7a",
+                "url": "https://api.github.com/repos/symfony/debug/zipball/8beb24eec70b345c313640962df933499373a944",
+                "reference": "8beb24eec70b345c313640962df933499373a944",
                 "shasum": ""
             },
             "require": {
-                "php": ">=5.5.9",
+                "php": "^5.5.9|>=7.0.8",
                 "psr/log": "~1.0"
             },
             "conflict": {
             ],
             "description": "Symfony Debug Component",
             "homepage": "https://symfony.com",
-            "time": "2017-05-27 16:02:27"
+            "time": "2017-09-01T13:23:39+00:00"
         },
         {
             "name": "symfony/dependency-injection",
-            "version": "v3.3.0",
+            "version": "v3.3.9",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/dependency-injection.git",
-                "reference": "988c7bd6ec880690792ccf2a1e5ca05401c2a63d"
+                "reference": "e593f06dd90a81c7b70ac1c49862a061b0ec06d2"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/dependency-injection/zipball/988c7bd6ec880690792ccf2a1e5ca05401c2a63d",
-                "reference": "988c7bd6ec880690792ccf2a1e5ca05401c2a63d",
+                "url": "https://api.github.com/repos/symfony/dependency-injection/zipball/e593f06dd90a81c7b70ac1c49862a061b0ec06d2",
+                "reference": "e593f06dd90a81c7b70ac1c49862a061b0ec06d2",
                 "shasum": ""
             },
             "require": {
-                "php": ">=5.5.9",
+                "php": "^5.5.9|>=7.0.8",
                 "psr/container": "^1.0"
             },
             "conflict": {
-                "symfony/config": "<=3.3-beta1",
+                "symfony/config": "<3.3.1",
                 "symfony/finder": "<3.3",
                 "symfony/yaml": "<3.3"
             },
             ],
             "description": "Symfony DependencyInjection Component",
             "homepage": "https://symfony.com",
-            "time": "2017-05-25 23:10:31"
+            "time": "2017-09-05T20:39:38+00:00"
         },
         {
             "name": "symfony/dom-crawler",
-            "version": "v3.3.0",
+            "version": "v3.3.9",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/dom-crawler.git",
-                "reference": "fc2c588ce376e9fe04a7b8c79e3ec62fe32d95b1"
+                "reference": "6b511d7329b203a620f09a2288818d27dcc915ae"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/dom-crawler/zipball/fc2c588ce376e9fe04a7b8c79e3ec62fe32d95b1",
-                "reference": "fc2c588ce376e9fe04a7b8c79e3ec62fe32d95b1",
+                "url": "https://api.github.com/repos/symfony/dom-crawler/zipball/6b511d7329b203a620f09a2288818d27dcc915ae",
+                "reference": "6b511d7329b203a620f09a2288818d27dcc915ae",
                 "shasum": ""
             },
             "require": {
-                "php": ">=5.5.9",
+                "php": "^5.5.9|>=7.0.8",
                 "symfony/polyfill-mbstring": "~1.0"
             },
             "require-dev": {
             ],
             "description": "Symfony DomCrawler Component",
             "homepage": "https://symfony.com",
-            "time": "2017-05-25 23:10:31"
+            "time": "2017-09-11T15:55:22+00:00"
         },
         {
             "name": "symfony/event-dispatcher",
-            "version": "v3.3.0",
+            "version": "v3.3.9",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/event-dispatcher.git",
-                "reference": "a9f8b02b0ef07302eca92cd4bba73200b7980e9c"
+                "reference": "54ca9520a00386f83bca145819ad3b619aaa2485"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/a9f8b02b0ef07302eca92cd4bba73200b7980e9c",
-                "reference": "a9f8b02b0ef07302eca92cd4bba73200b7980e9c",
+                "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/54ca9520a00386f83bca145819ad3b619aaa2485",
+                "reference": "54ca9520a00386f83bca145819ad3b619aaa2485",
                 "shasum": ""
             },
             "require": {
-                "php": ">=5.5.9"
+                "php": "^5.5.9|>=7.0.8"
             },
             "conflict": {
                 "symfony/dependency-injection": "<3.3"
             ],
             "description": "Symfony EventDispatcher Component",
             "homepage": "https://symfony.com",
-            "time": "2017-05-04 12:23:07"
+            "time": "2017-07-29T21:54:42+00:00"
         },
         {
             "name": "symfony/filesystem",
-            "version": "v3.3.0",
+            "version": "v3.3.9",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/filesystem.git",
-                "reference": "c709670bf64721202ddbe4162846f250735842c0"
+                "reference": "b32a0e5f928d0fa3d1dd03c78d020777e50c10cb"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/filesystem/zipball/c709670bf64721202ddbe4162846f250735842c0",
-                "reference": "c709670bf64721202ddbe4162846f250735842c0",
+                "url": "https://api.github.com/repos/symfony/filesystem/zipball/b32a0e5f928d0fa3d1dd03c78d020777e50c10cb",
+                "reference": "b32a0e5f928d0fa3d1dd03c78d020777e50c10cb",
                 "shasum": ""
             },
             "require": {
-                "php": ">=5.5.9"
+                "php": "^5.5.9|>=7.0.8"
             },
             "type": "library",
             "extra": {
             ],
             "description": "Symfony Filesystem Component",
             "homepage": "https://symfony.com",
-            "time": "2017-05-28 14:08:56"
+            "time": "2017-07-29T21:54:42+00:00"
         },
         {
             "name": "symfony/polyfill-mbstring",
-            "version": "v1.3.0",
+            "version": "v1.5.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/polyfill-mbstring.git",
-                "reference": "e79d363049d1c2128f133a2667e4f4190904f7f4"
+                "reference": "7c8fae0ac1d216eb54349e6a8baa57d515fe8803"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/e79d363049d1c2128f133a2667e4f4190904f7f4",
-                "reference": "e79d363049d1c2128f133a2667e4f4190904f7f4",
+                "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/7c8fae0ac1d216eb54349e6a8baa57d515fe8803",
+                "reference": "7c8fae0ac1d216eb54349e6a8baa57d515fe8803",
                 "shasum": ""
             },
             "require": {
             "type": "library",
             "extra": {
                 "branch-alias": {
-                    "dev-master": "1.3-dev"
+                    "dev-master": "1.5-dev"
                 }
             },
             "autoload": {
                 "portable",
                 "shim"
             ],
-            "time": "2016-11-14 01:06:16"
+            "time": "2017-06-14T15:44:48+00:00"
         },
         {
             "name": "symfony/process",
-            "version": "v2.8.21",
+            "version": "v2.8.27",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/process.git",
-                "reference": "d54232f5682fda2f8bbebff7c81b864646867ab9"
+                "reference": "57e52a0a6a80ea0aec4fc1b785a7920a95cb88a8"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/process/zipball/d54232f5682fda2f8bbebff7c81b864646867ab9",
-                "reference": "d54232f5682fda2f8bbebff7c81b864646867ab9",
+                "url": "https://api.github.com/repos/symfony/process/zipball/57e52a0a6a80ea0aec4fc1b785a7920a95cb88a8",
+                "reference": "57e52a0a6a80ea0aec4fc1b785a7920a95cb88a8",
                 "shasum": ""
             },
             "require": {
             ],
             "description": "Symfony Process Component",
             "homepage": "https://symfony.com",
-            "time": "2017-05-08 01:19:21"
+            "time": "2017-07-03T08:04:30+00:00"
         },
         {
             "name": "symfony/translation",
-            "version": "v3.3.0",
+            "version": "v3.3.9",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/translation.git",
-                "reference": "dc3b2a0c6cfff60327ba1c043a82092735397543"
+                "reference": "add53753d978f635492dfe8cd6953f6a7361ef90"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/translation/zipball/dc3b2a0c6cfff60327ba1c043a82092735397543",
-                "reference": "dc3b2a0c6cfff60327ba1c043a82092735397543",
+                "url": "https://api.github.com/repos/symfony/translation/zipball/add53753d978f635492dfe8cd6953f6a7361ef90",
+                "reference": "add53753d978f635492dfe8cd6953f6a7361ef90",
                 "shasum": ""
             },
             "require": {
-                "php": ">=5.5.9",
+                "php": "^5.5.9|>=7.0.8",
                 "symfony/polyfill-mbstring": "~1.0"
             },
             "conflict": {
             ],
             "description": "Symfony Translation Component",
             "homepage": "https://symfony.com",
-            "time": "2017-05-22 07:42:36"
+            "time": "2017-07-29T21:54:42+00:00"
         },
         {
             "name": "symfony/yaml",
-            "version": "v3.3.0",
+            "version": "v3.3.9",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/yaml.git",
-                "reference": "885db865f6b2b918404a1fae28f9ac640f71f994"
+                "reference": "1d8c2a99c80862bdc3af94c1781bf70f86bccac0"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/yaml/zipball/885db865f6b2b918404a1fae28f9ac640f71f994",
-                "reference": "885db865f6b2b918404a1fae28f9ac640f71f994",
+                "url": "https://api.github.com/repos/symfony/yaml/zipball/1d8c2a99c80862bdc3af94c1781bf70f86bccac0",
+                "reference": "1d8c2a99c80862bdc3af94c1781bf70f86bccac0",
                 "shasum": ""
             },
             "require": {
-                "php": ">=5.5.9"
+                "php": "^5.5.9|>=7.0.8"
             },
             "require-dev": {
                 "symfony/console": "~2.8|~3.0"
             ],
             "description": "Symfony Yaml Component",
             "homepage": "https://symfony.com",
-            "time": "2017-05-28 10:56:20"
+            "time": "2017-07-29T21:54:42+00:00"
         },
         {
             "name": "webmozart/assert",
                 "check",
                 "validate"
             ],
-            "time": "2016-11-23 20:04:58"
+            "time": "2016-11-23T20:04:58+00:00"
         }
     ],
     "aliases": [],
index 8972162..9c3378c 100644 (file)
@@ -45,11 +45,24 @@ class mycourse extends \core_search\base {
      * Returns recordset containing required data for indexing courses.
      *
      * @param int $modifiedfrom timestamp
-     * @return \moodle_recordset
+     * @param \context|null $context Restriction context
+     * @return \moodle_recordset|null Recordset or null if no change possible
      */
-    public function get_recordset_by_timestamp($modifiedfrom = 0) {
+    public function get_document_recordset($modifiedfrom = 0, \context $context = null) {
         global $DB;
-        return $DB->get_recordset_select('course', 'timemodified >= ?', array($modifiedfrom), 'timemodified ASC');
+
+        list ($contextjoin, $contextparams) = $this->get_course_level_context_restriction_sql(
+                $context, 'c');
+        if ($contextjoin === null) {
+            return null;
+        }
+
+        return $DB->get_recordset_sql("
+                SELECT c.*
+                  FROM {course} c
+          $contextjoin
+                 WHERE c.timemodified >= ?
+              ORDER BY c.timemodified ASC", array_merge($contextparams, [$modifiedfrom]));
     }
 
     /**
index 3919365..73e4b80 100644 (file)
@@ -3135,6 +3135,7 @@ class core_course_external extends external_api {
      */
     public static function check_updates($courseid, $tocheck, $filter = array()) {
         global $CFG, $DB;
+        require_once($CFG->dirroot . "/course/lib.php");
 
         $params = self::validate_parameters(
             self::check_updates_parameters(),
index b38c641..c688791 100644 (file)
@@ -4266,3 +4266,32 @@ function course_require_view_participants($context) {
         throw new required_capability_exception($context, $viewparticipantscap, 'nopermissions', '');
     }
 }
+
+/**
+ * Return whether the user can download from the specified backup file area in the given context.
+ *
+ * @param string $filearea the backup file area. E.g. 'course', 'backup' or 'automated'.
+ * @param \context $context
+ * @param stdClass $user the user object. If not provided, the current user will be checked.
+ * @return bool true if the user is allowed to download in the context, false otherwise.
+ */
+function can_download_from_backup_filearea($filearea, \context $context, stdClass $user = null) {
+    $candownload = false;
+    switch ($filearea) {
+        case 'course':
+        case 'backup':
+            $candownload = has_capability('moodle/backup:downloadfile', $context, $user);
+            break;
+        case 'automated':
+            // Given the automated backups may contain userinfo, we restrict access such that only users who are able to
+            // restore with userinfo are able to download the file. Users can't create these backups, so checking 'backup:userinfo'
+            // doesn't make sense here.
+            $candownload = has_capability('moodle/backup:downloadfile', $context, $user) &&
+                           has_capability('moodle/restore:userinfo', $context, $user);
+            break;
+        default:
+            break;
+
+    }
+    return $candownload;
+}
index b1ef9e9..64b2575 100644 (file)
@@ -456,7 +456,7 @@ class core_course_renderer extends plugin_renderer_base {
      * @return string
      */
     public function course_section_cm_completion($course, &$completioninfo, cm_info $mod, $displayoptions = array()) {
-        global $CFG;
+        global $CFG, $DB;
         $output = '';
         if (!empty($displayoptions['hidecompletion']) || !isloggedin() || isguestuser() || !$mod->uservisible) {
             return $output;
@@ -485,16 +485,20 @@ class core_course_renderer extends plugin_renderer_base {
         } else if ($completion == COMPLETION_TRACKING_MANUAL) {
             switch($completiondata->completionstate) {
                 case COMPLETION_INCOMPLETE:
-                    $completionicon = 'manual-n'; break;
+                    $completionicon = 'manual-n' . ($completiondata->overrideby ? '-override' : '');
+                    break;
                 case COMPLETION_COMPLETE:
-                    $completionicon = 'manual-y'; break;
+                    $completionicon = 'manual-y' . ($completiondata->overrideby ? '-override' : '');
+                    break;
             }
         } else { // Automatic
             switch($completiondata->completionstate) {
                 case COMPLETION_INCOMPLETE:
-                    $completionicon = 'auto-n'; break;
+                    $completionicon = 'auto-n' . ($completiondata->overrideby ? '-override' : '');
+                    break;
                 case COMPLETION_COMPLETE:
-                    $completionicon = 'auto-y'; break;
+                    $completionicon = 'auto-y' . ($completiondata->overrideby ? '-override' : '');
+                    break;
                 case COMPLETION_COMPLETE_PASS:
                     $completionicon = 'auto-pass'; break;
                 case COMPLETION_COMPLETE_FAIL:
@@ -503,7 +507,15 @@ class core_course_renderer extends plugin_renderer_base {
         }
         if ($completionicon) {
             $formattedname = $mod->get_formatted_name();
-            $imgalt = get_string('completion-alt-' . $completionicon, 'completion', $formattedname);
+            if ($completiondata->overrideby) {
+                $args = new stdClass();
+                $args->modname = $formattedname;
+                $overridebyuser = \core_user::get_user($completiondata->overrideby, '*', MUST_EXIST);
+                $args->overrideuser = fullname($overridebyuser);
+                $imgalt = get_string('completion-alt-' . $completionicon, 'completion', $args);
+            } else {
+                $imgalt = get_string('completion-alt-' . $completionicon, 'completion', $formattedname);
+            }
 
             if ($this->page->user_is_editing()) {
                 // When editing, the icon is just an image.
@@ -512,7 +524,6 @@ class core_course_renderer extends plugin_renderer_base {
                 $output .= html_writer::tag('span', $this->output->render($completionpixicon),
                         array('class' => 'autocompletion'));
             } else if ($completion == COMPLETION_TRACKING_MANUAL) {
-                $imgtitle = get_string('completion-title-' . $completionicon, 'completion', $formattedname);
                 $newstate =
                     $completiondata->completionstate == COMPLETION_COMPLETE
                     ? COMPLETION_INCOMPLETE
index 6d53ba5..78f1934 100644 (file)
@@ -4074,4 +4074,54 @@ class core_course_courselib_testcase extends advanced_testcase {
         $this->expectException('required_capability_exception');
         course_require_view_participants(context_system::instance());
     }
+
+    /**
+     *  Testing the can_download_from_backup_filearea fn.
+     */
+    public function test_can_download_from_backup_filearea() {
+        global $DB;
+        $this->resetAfterTest();
+        $course = $this->getDataGenerator()->create_course();
+        $context = context_course::instance($course->id);
+        $user = $this->getDataGenerator()->create_user();
+        $teacherrole = $DB->get_record('role', array('shortname' => 'teacher'));
+        $this->getDataGenerator()->enrol_user($user->id, $course->id, $teacherrole->id);
+
+        // The 'automated' backup area. Downloading from this area requires two capabilities.
+        // If the user has only the 'backup:downloadfile' capability.
+        unassign_capability('moodle/restore:userinfo', $teacherrole->id, $context);
+        assign_capability('moodle/backup:downloadfile', CAP_ALLOW, $teacherrole->id, $context);
+        $this->assertFalse(can_download_from_backup_filearea('automated', $context, $user));
+
+        // If the user has only the 'restore:userinfo' capability.
+        unassign_capability('moodle/backup:downloadfile', $teacherrole->id, $context);
+        assign_capability('moodle/restore:userinfo', CAP_ALLOW, $teacherrole->id, $context);
+        $this->assertFalse(can_download_from_backup_filearea('automated', $context, $user));
+
+        // If the user has both capabilities.
+        assign_capability('moodle/backup:downloadfile', CAP_ALLOW, $teacherrole->id, $context);
+        assign_capability('moodle/restore:userinfo', CAP_ALLOW, $teacherrole->id, $context);
+        $this->assertTrue(can_download_from_backup_filearea('automated', $context, $user));
+
+        // Is the user has neither of the capabilities.
+        unassign_capability('moodle/backup:downloadfile', $teacherrole->id, $context);
+        unassign_capability('moodle/restore:userinfo', $teacherrole->id, $context);
+        $this->assertFalse(can_download_from_backup_filearea('automated', $context, $user));
+
+        // The 'course ' and 'backup' backup file areas. These are governed by the same download capability.
+        // User has the capability.
+        unassign_capability('moodle/restore:userinfo', $teacherrole->id, $context);
+        assign_capability('moodle/backup:downloadfile', CAP_ALLOW, $teacherrole->id, $context);
+        $this->assertTrue(can_download_from_backup_filearea('course', $context, $user));
+        $this->assertTrue(can_download_from_backup_filearea('backup', $context, $user));
+
+        // User doesn't have the capability.
+        unassign_capability('moodle/backup:downloadfile', $teacherrole->id, $context);
+        $this->assertFalse(can_download_from_backup_filearea('course', $context, $user));
+        $this->assertFalse(can_download_from_backup_filearea('backup', $context, $user));
+
+        // A file area that doesn't exist. No permissions, regardless of capabilities.
+        assign_capability('moodle/backup:downloadfile', CAP_ALLOW, $teacherrole->id, $context);
+        $this->assertFalse(can_download_from_backup_filearea('testing', $context, $user));
+    }
 }
index e8ed2fa..b609280 100644 (file)
@@ -98,6 +98,85 @@ class course_search_testcase extends advanced_testcase {
         $recordset->close();
     }
 
+    /**
+     * Tests course indexing support for contexts.
+     */
+    public function test_mycourses_indexing_contexts() {
+        global $DB, $USER, $SITE;
+
+        $searcharea = \core_search\manager::get_search_area($this->mycoursesareaid);
+
+        // Create some courses in categories, and a forum.
+        $generator = $this->getDataGenerator();
+        $cat1 = $generator->create_category();
+        $course1 = $generator->create_course(['category' => $cat1->id]);
+        $cat2 = $generator->create_category(['parent' => $cat1->id]);
+        $course2 = $generator->create_course(['category' => $cat2->id]);
+        $cat3 = $generator->create_category();
+        $course3 = $generator->create_course(['category' => $cat3->id]);
+        $forum = $generator->create_module('forum', ['course' => $course1->id]);
+        $DB->set_field('course', 'timemodified', 0, ['id' => $SITE->id]);
+        $DB->set_field('course', 'timemodified', 1, ['id' => $course1->id]);
+        $DB->set_field('course', 'timemodified', 2, ['id' => $course2->id]);
+        $DB->set_field('course', 'timemodified', 3, ['id' => $course3->id]);
+
+        // Find the first block to use for a block context.
+        $blockid = array_values($DB->get_records('block_instances', null, 'id', 'id', 0, 1))[0]->id;
+        $blockcontext = context_block::instance($blockid);
+
+        // Check with block context - should be null.
+        $this->assertNull($searcharea->get_document_recordset(0, $blockcontext));
+
+        // Check with user context - should be null.
+        $this->setAdminUser();
+        $usercontext = context_user::instance($USER->id);
+        $this->assertNull($searcharea->get_document_recordset(0, $usercontext));
+
+        // Check with module context - should be null.
+        $modcontext = context_module::instance($forum->cmid);
+        $this->assertNull($searcharea->get_document_recordset(0, $modcontext));
+
+        // Check with course context - should return specified course if timestamp allows.
+        $coursecontext = context_course::instance($course3->id);
+        $results = self::recordset_to_ids($searcharea->get_document_recordset(3, $coursecontext));
+        $this->assertEquals([$course3->id], $results);
+        $results = self::recordset_to_ids($searcharea->get_document_recordset(4, $coursecontext));
+        $this->assertEquals([], $results);
+
+        // Check with category context - should return course in categories and subcategories.
+        $catcontext = context_coursecat::instance($cat1->id);
+        $results = self::recordset_to_ids($searcharea->get_document_recordset(0, $catcontext));
+        $this->assertEquals([$course1->id, $course2->id], $results);
+        $results = self::recordset_to_ids($searcharea->get_document_recordset(2, $catcontext));
+        $this->assertEquals([$course2->id], $results);
+
+        // Check with system context and null - should return all these courses + site course.
+        $systemcontext = context_system::instance();
+        $results = self::recordset_to_ids($searcharea->get_document_recordset(0, $systemcontext));
+        $this->assertEquals([$SITE->id, $course1->id, $course2->id, $course3->id], $results);
+        $results = self::recordset_to_ids($searcharea->get_document_recordset(0, null));
+        $this->assertEquals([$SITE->id, $course1->id, $course2->id, $course3->id], $results);
+        $results = self::recordset_to_ids($searcharea->get_document_recordset(3, $systemcontext));
+        $this->assertEquals([$course3->id], $results);
+        $results = self::recordset_to_ids($searcharea->get_document_recordset(3, null));
+        $this->assertEquals([$course3->id], $results);
+    }
+
+    /**
+     * Utility function to convert recordset to array of IDs for testing.
+     *
+     * @param moodle_recordset $rs Recordset to convert (and close)
+     * @return array Array of IDs from records indexed by number (0, 1, 2, ...)
+     */
+    protected static function recordset_to_ids(moodle_recordset $rs) {
+        $results = [];
+        foreach ($rs as $rec) {
+            $results[] = $rec->id;
+        }
+        $rs->close();
+        return $results;
+    }
+
     /**
      * Document contents.
      *
index 9e16980..a44b813 100644 (file)
@@ -221,28 +221,6 @@ class enrol_cohort_plugin extends enrol_plugin {
         return false;
     }
 
-    /**
-     * Gets an array of the user enrolment actions.
-     *
-     * @param course_enrolment_manager $manager
-     * @param stdClass $ue A user enrolment object
-     * @return array An array of user_enrolment_actions
-     */
-    public function get_user_enrolment_actions(course_enrolment_manager $manager, $ue) {
-        $actions = array();
-        $context = $manager->get_context();
-        $instance = $ue->enrolmentinstance;
-        $params = $manager->get_moodlepage()->url->params();
-        $params['ue'] = $ue->id;
-        if ($this->allow_unenrol_user($instance, $ue) && has_capability('enrol/cohort:unenrol', $context)) {
-            $url = new moodle_url('/enrol/unenroluser.php', $params);
-            $actionparams = array('class' => 'unenrollink', 'rel' => $ue->id, 'data-action' => ENROL_ACTION_UNENROL);
-            $actions[] = new user_enrolment_action(new pix_icon('t/delete', get_string('unenrol', 'enrol')),
-                get_string('unenrol', 'enrol'), $url, $actionparams);
-        }
-        return $actions;
-    }
-
     /**
      * Restore instance and map settings.
      *
index 3744c7c..080f0dd 100644 (file)
@@ -83,4 +83,56 @@ class enrol_cohort_lib_testcase extends advanced_testcase {
         $this->assertEquals($cohort->name . ' cohort (3)', $groupinfo->name);
 
     }
+
+    /**
+     * Test for getting user enrolment actions.
+     */
+    public function test_get_user_enrolment_actions() {
+        global $CFG, $PAGE;
+        $this->resetAfterTest();
+
+        // Set page URL to prevent debugging messages.
+        $PAGE->set_url('/enrol/editinstance.php');
+
+        $pluginname = 'cohort';
+
+        // Only enable the cohort enrol plugin.
+        $CFG->enrol_plugins_enabled = $pluginname;
+
+        $generator = $this->getDataGenerator();
+
+        // Get the enrol plugin.
+        $plugin = enrol_get_plugin($pluginname);
+
+        // Create a course.
+        $course = $generator->create_course();
+        // Enable this enrol plugin for the course.
+        $plugin->add_instance($course);
+
+        // Create a student.
+        $student = $generator->create_user();
+        // Enrol the student to the course.
+        $generator->enrol_user($student->id, $course->id, 'student', $pluginname);
+
+        // Teachers don't have enrol/cohort:unenrol capability by default. Login as admin for simplicity.
+        $this->setAdminUser();
+        require_once($CFG->dirroot . '/enrol/locallib.php');
+        $manager = new course_enrolment_manager($PAGE, $course);
+
+        $userenrolments = $manager->get_user_enrolments($student->id);
+        $this->assertCount(1, $userenrolments);
+
+        $ue = reset($userenrolments);
+        $actions = $plugin->get_user_enrolment_actions($manager, $ue);
+        // Cohort-sync has no enrol actions for active students.
+        $this->assertCount(0, $actions);
+
+        // Enrol actions for a suspended student.
+        // Suspend the student.
+        $ue->status = ENROL_USER_SUSPENDED;
+
+        $actions = $plugin->get_user_enrolment_actions($manager, $ue);
+        // Cohort-sync has enrol actions for suspended students -- unenrol.
+        $this->assertCount(1, $actions);
+    }
 }
index c9b3686..1ecb250 100644 (file)
@@ -82,28 +82,6 @@ class enrol_database_plugin extends enrol_plugin {
         return false;
     }
 
-    /**
-     * Gets an array of the user enrolment actions.
-     *
-     * @param course_enrolment_manager $manager
-     * @param stdClass $ue A user enrolment object
-     * @return array An array of user_enrolment_actions
-     */
-    public function get_user_enrolment_actions(course_enrolment_manager $manager, $ue) {
-        $actions = array();
-        $context = $manager->get_context();
-        $instance = $ue->enrolmentinstance;
-        $params = $manager->get_moodlepage()->url->params();
-        $params['ue'] = $ue->id;
-        if ($this->allow_unenrol_user($instance, $ue) && has_capability('enrol/database:unenrol', $context)) {
-            $url = new moodle_url('/enrol/unenroluser.php', $params);
-            $actionparams = array('class' => 'unenrollink', 'rel' => $ue->id, 'data-action' => ENROL_ACTION_UNENROL);
-            $actions[] = new user_enrolment_action(new pix_icon('t/delete', get_string('unenrol', 'enrol')),
-                get_string('unenrol', 'enrol'), $url, $actionparams);
-        }
-        return $actions;
-    }
-
     /**
      * Forces synchronisation of user enrolments with external database,
      * does not create new courses.
diff --git a/enrol/database/tests/lib_test.php b/enrol/database/tests/lib_test.php
new file mode 100644 (file)
index 0000000..ee80a91
--- /dev/null
@@ -0,0 +1,86 @@
+<?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
+//&n