Merge branch 'MDL-69582-310' of git://github.com/ferranrecio/moodle into MOODLE_310_S...
authorEloy Lafuente (stronk7) <stronk7@moodle.org>
Thu, 8 Oct 2020 20:44:29 +0000 (22:44 +0200)
committerEloy Lafuente (stronk7) <stronk7@moodle.org>
Thu, 8 Oct 2020 20:44:29 +0000 (22:44 +0200)
367 files changed:
.eslintignore
.stylelintignore
.travis.yml
admin/admin_settings_search_form.php [deleted file]
admin/cli/svgtool.php
admin/search.php
admin/settings/location.php
admin/settings/server.php
admin/tests/behat/behat_admin.php
admin/tests/behat/invalid_allcountrycodes.feature [new file with mode: 0644]
admin/tool/capability/yui/build/moodle-tool_capability-search/moodle-tool_capability-search-debug.js
admin/tool/capability/yui/build/moodle-tool_capability-search/moodle-tool_capability-search-min.js
admin/tool/capability/yui/build/moodle-tool_capability-search/moodle-tool_capability-search.js
admin/tool/capability/yui/src/search/js/search.js
admin/tool/dataprivacy/classes/external.php
admin/tool/dataprivacy/classes/output/renderer.php
admin/tool/dbtransfer/locallib.php
admin/tool/mobile/classes/api.php
admin/tool/mobile/classes/external.php
admin/tool/mobile/lang/en/deprecated.txt [new file with mode: 0644]
admin/tool/mobile/lang/en/tool_mobile.php
admin/tool/mobile/lib.php
admin/tool/mobile/tests/externallib_test.php
admin/tool/replace/classes/form.php
admin/tool/replace/cli/replace.php
admin/tool/replace/index.php
admin/tool/replace/lang/en/tool_replace.php
admin/tool/templatelibrary/amd/build/search.min.js
admin/tool/templatelibrary/amd/build/search.min.js.map
admin/tool/templatelibrary/amd/src/search.js
admin/tool/templatelibrary/templates/list_templates_page.mustache
admin/tool/uploaduser/classes/cli_helper.php [new file with mode: 0644]
admin/tool/uploaduser/classes/local/cli_progress_tracker.php [new file with mode: 0644]
admin/tool/uploaduser/classes/local/text_progress_tracker.php [new file with mode: 0644]
admin/tool/uploaduser/classes/preview.php [new file with mode: 0644]
admin/tool/uploaduser/classes/process.php [new file with mode: 0644]
admin/tool/uploaduser/cli/uploaduser.php [new file with mode: 0644]
admin/tool/uploaduser/index.php
admin/tool/uploaduser/lang/en/tool_uploaduser.php
admin/tool/uploaduser/locallib.php
admin/tool/uploaduser/tests/cli_test.php [new file with mode: 0644]
admin/tool/uploaduser/user_form.php
admin/tool/usertours/classes/privacy/provider.php
admin/tool/usertours/classes/tour.php
admin/tool/usertours/db/upgrade.php
admin/tool/usertours/tests/privacy_provider_test.php
admin/tool/usertours/tests/tour_test.php
admin/tool/usertours/version.php
admin/webservice/testclient.php
auth/email/classes/external.php
auth/email/tests/external_test.php
availability/condition/grade/tests/behat/availability_grade.feature
backup/moodle2/restore_stepslib.php
backup/util/ui/renderer.php
badges/tests/badgeslib_test.php
blocks/blog_menu/block_blog_menu.php
blocks/blog_menu/tests/behat/block_blog_menu_activity.feature
blocks/blog_menu/tests/behat/block_blog_menu_course.feature
blocks/classes/external.php
blocks/globalsearch/block_globalsearch.php
blocks/search_forums/classes/output/search_form.php
blocks/search_forums/templates/search_form.mustache
blocks/search_forums/tests/behat/block_search_forums_course.feature
blocks/search_forums/tests/behat/block_search_forums_frontpage.feature
blocks/settings/renderer.php
blocks/tests/externallib_test.php
cache/admin.php
cache/classes/administration_helper.php [new file with mode: 0644]
cache/classes/factory.php
cache/classes/helper.php
cache/classes/local/administration_display_helper.php [new file with mode: 0644]
cache/forms.php
cache/locallib.php
cache/renderer.php
cache/tests/administration_helper_test.php
cache/upgrade.txt
calendar/classes/external/export/token.php [new file with mode: 0644]
calendar/export.php
calendar/export_execute.php
calendar/externallib.php
calendar/lib.php
calendar/tests/externallib_test.php
calendar/tests/lib_test.php
cohort/index.php
competency/classes/external/competency_framework_exporter.php
completion/completion_completion.php
completion/tests/behat/restrict_activity_by_grade.feature
completion/tests/behat/restrict_section_availability.feature
composer.json
composer.lock
config-dist.php
contentbank/amd/build/search.min.js
contentbank/amd/build/search.min.js.map
contentbank/amd/build/selectors.min.js
contentbank/amd/build/selectors.min.js.map
contentbank/amd/src/search.js
contentbank/amd/src/selectors.js
contentbank/classes/contentbank.php
contentbank/edit.php
contentbank/index.php
contentbank/templates/bankcontent/search.mustache
contentbank/tests/contentbank_test.php
contentbank/upload.php
course/amd/build/local/activitychooser/dialogue.min.js
course/amd/build/local/activitychooser/dialogue.min.js.map
course/amd/build/local/activitychooser/selectors.min.js
course/amd/build/local/activitychooser/selectors.min.js.map
course/amd/src/local/activitychooser/dialogue.js
course/amd/src/local/activitychooser/selectors.js
course/classes/category.php
course/classes/local/service/content_item_service.php
course/classes/management_renderer.php
course/externallib.php
course/lib.php
course/management.php
course/renderer.php
course/search.php
course/templates/local/activitychooser/search.mustache
course/tests/behat/course_search.feature
course/tests/externallib_test.php
course/upgrade.txt
enrol/externallib.php
enrol/tests/externallib_test.php
filter/algebra/filter.php
filter/tex/lib.php
grade/grading/form/guide/tests/behat/edit_guide.feature
grade/grading/tests/behat/behat_grading.php
grade/report/singleview/classes/local/screen/screen.php
grade/report/singleview/js/singleview.js
grade/report/singleview/lang/en/gradereport_singleview.php
grade/report/singleview/tests/behat/bulk_insert_grades.feature
grade/tests/behat/grade_scales.feature
grade/tests/behat/grade_single_item_scales.feature
h5p/ajax.php
h5p/classes/editor_framework.php
h5p/classes/external.php
h5p/classes/framework.php
h5p/classes/output/renderer.php
h5p/classes/player.php
h5p/tests/framework_test.php
lang/en/admin.php
lang/en/completion.php
lang/en/contentbank.php
lang/en/deprecated.txt
lang/en/error.php
lang/en/form.php
lang/en/moodle.php
lang/en/repository.php
lib/adminlib.php
lib/amd/build/notification.min.js
lib/amd/build/notification.min.js.map
lib/amd/build/search-input.min.js [deleted file]
lib/amd/build/search-input.min.js.map [deleted file]
lib/amd/build/templates.min.js.map
lib/amd/src/notification.js
lib/amd/src/search-input.js [deleted file]
lib/amd/src/templates.js
lib/classes/component.php
lib/classes/files/curl_security_helper.php
lib/classes/notification.php
lib/classes/oauth2/api.php
lib/classes/oauth2/client.php
lib/classes/output/mustache_engine.php
lib/classes/output/mustache_helper_collection.php
lib/classes/privacy/provider.php
lib/classes/string_manager_standard.php
lib/classes/user.php
lib/datalib.php
lib/db/caches.php
lib/db/install.php
lib/db/install.xml
lib/db/messages.php
lib/db/services.php
lib/db/upgrade.php
lib/deprecatedlib.php
lib/enrollib.php
lib/external/externallib.php
lib/external/tests/external_test.php
lib/externallib.php
lib/filelib.php
lib/filestorage/tests/fixtures/passwordis1.zip [new file with mode: 0644]
lib/filestorage/tests/zip_packer_test.php
lib/form/classes/filetypes_util.php
lib/form/filemanager.php
lib/form/filepicker.php
lib/form/filetypes.php
lib/form/tests/behat/modgrade_validation.feature
lib/form/tests/filetypes_util_test.php
lib/http-message/LICENSE [new file with mode: 0644]
lib/http-message/readme_moodle.txt [new file with mode: 0644]
lib/http-message/src/MessageInterface.php [new file with mode: 0644]
lib/http-message/src/RequestInterface.php [new file with mode: 0644]
lib/http-message/src/ResponseInterface.php [new file with mode: 0644]
lib/http-message/src/ServerRequestInterface.php [new file with mode: 0644]
lib/http-message/src/StreamInterface.php [new file with mode: 0644]
lib/http-message/src/UploadedFileInterface.php [new file with mode: 0644]
lib/http-message/src/UriInterface.php [new file with mode: 0644]
lib/licenselib.php
lib/moodlelib.php
lib/myprofilelib.php
lib/outputrenderers.php
lib/php-enum/LICENSE [new file with mode: 0644]
lib/php-enum/readme_moodle.txt [new file with mode: 0644]
lib/php-enum/src/Enum.php [new file with mode: 0644]
lib/phpunit/classes/util.php
lib/setuplib.php
lib/templates/checkbox.mustache [new file with mode: 0644]
lib/templates/popover_region.mustache
lib/templates/search_input.mustache [new file with mode: 0644]
lib/templates/search_input_auto.mustache [new file with mode: 0644]
lib/templates/search_input_navbar.mustache [new file with mode: 0644]
lib/tests/adminlib_test.php
lib/tests/completionlib_test.php
lib/tests/curl_security_helper_test.php
lib/tests/datalib_test.php
lib/tests/moodlelib_test.php
lib/tests/notification_test.php
lib/tests/output_mustache_helper_collection_test.php
lib/tests/setuplib_test.php
lib/tests/string_manager_standard_test.php
lib/thirdpartylibs.xml
lib/upgrade.txt
lib/upgradelib.php
lib/yui/build/moodle-core-notification-exception/moodle-core-notification-exception-debug.js
lib/yui/build/moodle-core-notification-exception/moodle-core-notification-exception.js
lib/yui/src/notification/js/exception.js
lib/zipstream/LICENSE [new file with mode: 0644]
lib/zipstream/readme_moodle.txt [new file with mode: 0644]
lib/zipstream/src/Bigint.php [new file with mode: 0644]
lib/zipstream/src/DeflateStream.php [new file with mode: 0644]
lib/zipstream/src/Exception.php [new file with mode: 0644]
lib/zipstream/src/Exception/EncodingException.php [new file with mode: 0644]
lib/zipstream/src/Exception/FileNotFoundException.php [new file with mode: 0644]
lib/zipstream/src/Exception/FileNotReadableException.php [new file with mode: 0644]
lib/zipstream/src/Exception/IncompatibleOptionsException.php [new file with mode: 0644]
lib/zipstream/src/Exception/OverflowException.php [new file with mode: 0644]
lib/zipstream/src/Exception/StreamNotReadableException.php [new file with mode: 0644]
lib/zipstream/src/File.php [new file with mode: 0644]
lib/zipstream/src/Option/Archive.php [new file with mode: 0644]
lib/zipstream/src/Option/File.php [new file with mode: 0644]
lib/zipstream/src/Option/Method.php [new file with mode: 0644]
lib/zipstream/src/Option/Version.php [new file with mode: 0644]
lib/zipstream/src/Stream.php [new file with mode: 0644]
lib/zipstream/src/ZipStream.php [new file with mode: 0644]
login/token.php
message/externallib.php
message/lib.php
message/templates/message_drawer_view_overview_header.mustache
message/templates/message_drawer_view_search_header.mustache
message/templates/message_popover.mustache
message/tests/externallib_test.php
mnet/xmlrpc/serverlib.php
mod/assign/amd/build/grading_panel.min.js
mod/assign/amd/build/grading_panel.min.js.map
mod/assign/amd/src/grading_panel.js
mod/assign/externallib.php
mod/assign/feedback/editpdf/lib.php
mod/assign/feedback/editpdf/locallib.php
mod/assign/feedback/editpdf/tests/behat/annotate_pdf.feature
mod/assign/feedback/editpdf/tests/behat/group_annotations.feature
mod/assign/feedback/editpdf/tests/behat/view_previous_annotations.feature
mod/assign/feedback/file/tests/behat/feedback_file.feature
mod/assign/tests/behat/allow_another_attempt.feature
mod/assign/tests/behat/assign_hidden.feature
mod/assign/tests/behat/comment_inline.feature
mod/assign/tests/behat/display_grade.feature
mod/assign/tests/behat/edit_previous_feedback.feature
mod/assign/tests/behat/filter_by_marker.feature
mod/assign/tests/behat/grading_app_filters.feature
mod/assign/tests/behat/grading_status.feature
mod/assign/tests/behat/group_submission.feature
mod/assign/tests/behat/hide_grader.feature
mod/assign/tests/behat/outcome_grading.feature
mod/assign/tests/behat/prevent_submission_changes.feature
mod/assign/tests/behat/quickgrading.feature
mod/assign/tests/behat/relative_dates.feature
mod/assign/tests/behat/rescale_grades.feature
mod/assign/tests/behat/steps_blind_marking.feature
mod/assign/tests/behat/submission_comments.feature
mod/book/db/install.xml
mod/book/db/upgrade.php
mod/book/version.php
mod/data/index.php
mod/data/mod_form.php
mod/data/tests/behat/create_activity.feature [new file with mode: 0644]
mod/data/tests/behat/data_activities.feature [new file with mode: 0644]
mod/folder/lib.php
mod/forum/classes/output/quick_search_form.php
mod/forum/externallib.php
mod/forum/templates/inpage_reply.mustache
mod/forum/templates/inpage_reply_v2.mustache
mod/forum/templates/quick_search_form.mustache
mod/forum/tests/behat/behat_mod_forum.php
mod/glossary/view.php
mod/lesson/essay.php
mod/lesson/locallib.php
mod/lesson/mod_form.php
mod/lesson/pagetypes/essay.php
mod/lesson/pagetypes/matching.php
mod/lesson/pagetypes/multichoice.php
mod/lesson/pagetypes/numerical.php
mod/lesson/pagetypes/shortanswer.php
mod/lesson/pagetypes/truefalse.php
mod/lesson/tests/behat/lesson_navigation.feature
mod/lesson/tests/locallib_test.php
mod/lti/amd/build/contentitem.min.js
mod/lti/amd/build/contentitem.min.js.map
mod/lti/amd/src/contentitem.js
mod/lti/auth.php
mod/lti/edit_form.php
mod/lti/lang/en/deprecated.txt
mod/lti/lang/en/lti.php
mod/lti/locallib.php
mod/lti/mod_form.js
mod/lti/mod_form.php
mod/lti/templates/tool_deeplinking_results.mustache [new file with mode: 0644]
mod/lti/tests/behat/contentitem.feature
mod/lti/tests/behat/contentitemregistration.feature
mod/lti/tests/locallib_test.php
mod/lti/upgrade.txt
mod/quiz/attemptlib.php
mod/quiz/classes/external.php
mod/quiz/module.js
mod/quiz/renderer.php
mod/quiz/styles.css
mod/quiz/templates/timer.mustache [moved from blocks/settings/templates/search_form.mustache with 52% similarity]
mod/quiz/tests/external_test.php
mod/quiz/upgrade.txt
mod/url/lib.php
mod/wiki/lib.php
mod/workshop/locallib.php
question/format/xml/format.php
question/format/xml/tests/fixtures/html_chars_in_idnumbers.xml [new file with mode: 0644]
question/format/xml/tests/qformat_xml_import_export_test.php
question/type/essay/backup/moodle2/backup_qtype_essay_plugin.class.php
question/type/essay/db/install.xml
question/type/essay/db/upgrade.php
question/type/essay/edit_essay_form.php
question/type/essay/lang/en/qtype_essay.php
question/type/essay/question.php
question/type/essay/questiontype.php
question/type/essay/renderer.php
question/type/essay/styles.css
question/type/essay/tests/behat/max_file_size.feature [new file with mode: 0644]
question/type/essay/tests/helper.php
question/type/essay/version.php
repository/draftfiles_ajax.php
repository/googledocs/lib.php
repository/nextcloud/lib.php
repository/onedrive/lib.php
search/tests/behat/behat_search.php
tag/manage.php
theme/boost/scss/moodle/admin.scss
theme/boost/scss/moodle/core.scss
theme/boost/scss/moodle/modules.scss
theme/boost/scss/moodle/popover-region.scss
theme/boost/scss/moodle/search.scss
theme/boost/style/moodle.css
theme/boost/templates/navbar.mustache
theme/classic/style/moodle.css
theme/classic/templates/navbar.mustache
user/editlib.php
user/tests/behat/view_full_profile.feature
version.php
webservice/lib.php
webservice/tests/helpers.php
webservice/upgrade.txt

index e4546c5..0d0a6ea 100644 (file)
@@ -67,6 +67,9 @@ lib/babel-polyfill/
 lib/polyfills/
 lib/emoji-data/
 lib/plist/
+lib/zipstream/
+lib/php-enum/
+lib/http-message/
 media/player/videojs/amd/src/video-lazy.js
 media/player/videojs/amd/src/Youtube-lazy.js
 media/player/videojs/videojs/
index 5fae1d4..0207e41 100644 (file)
@@ -68,6 +68,9 @@ lib/babel-polyfill/
 lib/polyfills/
 lib/emoji-data/
 lib/plist/
+lib/zipstream/
+lib/php-enum/
+lib/http-message/
 media/player/videojs/amd/src/video-lazy.js
 media/player/videojs/amd/src/Youtube-lazy.js
 media/player/videojs/videojs/
index da6a827..d103df0 100644 (file)
@@ -2,10 +2,9 @@
 # process (which uses our internal CI system) this file is here for the benefit
 # of community developers git clones - see MDL-51458.
 
-# We currently disable Travis notifications entirely until https://github.com/travis-ci/travis-ci/issues/4976
-# is fixed.
 notifications:
-  email: false
+  email:
+    if: env(MOODLE_EMAIL) != no
 
 language: php
 
diff --git a/admin/admin_settings_search_form.php b/admin/admin_settings_search_form.php
deleted file mode 100644 (file)
index ad42300..0000000
+++ /dev/null
@@ -1,48 +0,0 @@
-<?php
-// This file is part of Moodle - http://moodle.org/
-//
-// Moodle is free software: you can redistribute it and/or modify
-// it under the terms of the GNU General Public License as published by
-// the Free Software Foundation, either version 3 of the License, or
-// (at your option) any later version.
-//
-// Moodle is distributed in the hope that it will be useful,
-// but WITHOUT ANY WARRANTY; without even the implied warranty of
-// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-// GNU General Public License for more details.
-//
-// You should have received a copy of the GNU General Public License
-// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
-
-/**
- * Admin settings search form
- *
- * @package    admin
- * @copyright  2016 Damyon Wiese
- * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
- */
-
-defined('MOODLE_INTERNAL') || die();
-
-require_once $CFG->libdir.'/formslib.php';
-
-/**
- * Admin settings search form
- *
- * @package    admin
- * @copyright  2016 Damyon Wiese
- * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
- */
-class admin_settings_search_form extends moodleform {
-    function definition () {
-        $mform = $this->_form;
-
-        //$mform->addElement('header', 'settingsheader', get_string('search', 'admin'));
-        $elements = [];
-        $elements[] = $mform->createElement('text', 'query', get_string('query', 'admin'));
-        $elements[] = $mform->createElement('submit', 'search', get_string('search'));
-        $mform->addGroup($elements);
-        $mform->setType('query', PARAM_RAW);
-        $mform->setDefault('query', optional_param('query', '', PARAM_RAW));
-    }
-}
index 929a7c9..e83ef1d 100644 (file)
@@ -38,17 +38,17 @@ if ($unrecognized) {
 }
 
 // If necessary add files that should be ignored - such as in 3rd party plugins.
-$blacklist = array();
+$ignorelist = array();
 $path = $options['path'];
 if (!file_exists($path)) {
     cli_error("Invalid path $path");
 }
 
 if ($options['ie9fix']) {
-    core_admin_recurse_svgs($path, '', 'core_admin_svgtool_ie9fix', $blacklist);
+    core_admin_recurse_svgs($path, '', 'core_admin_svgtool_ie9fix', $ignorelist);
 
 } else if ($options['noaspectratio']) {
-    core_admin_recurse_svgs($path, '', 'core_admin_svgtool_noaspectratio', $blacklist);
+    core_admin_recurse_svgs($path, '', 'core_admin_svgtool_noaspectratio', $ignorelist);
 
 } else {
     $help =
@@ -153,9 +153,9 @@ function core_admin_svgtool_noaspectratio($file) {
  * @param string $base
  * @param string $sub
  * @param string $filecallback
- * @param array $blacklist
+ * @param array $ignorelist List of files to be ignored and skipped.
  */
-function core_admin_recurse_svgs($base, $sub, $filecallback, $blacklist) {
+function core_admin_recurse_svgs($base, $sub, $filecallback, $ignorelist) {
     if (is_dir("$base/$sub")) {
         $items = new DirectoryIterator("$base/$sub");
         foreach ($items as $item) {
@@ -163,7 +163,7 @@ function core_admin_recurse_svgs($base, $sub, $filecallback, $blacklist) {
                 continue;
             }
             $file = $item->getFilename();
-            core_admin_recurse_svgs("$base/$sub", $file, $filecallback, $blacklist);
+            core_admin_recurse_svgs("$base/$sub", $file, $filecallback, $ignorelist);
         }
         unset($item);
         unset($items);
@@ -174,7 +174,7 @@ function core_admin_recurse_svgs($base, $sub, $filecallback, $blacklist) {
             return;
         }
         $file = realpath("$base/$sub");
-        if (in_array($file, $blacklist)) {
+        if (in_array($file, $ignorelist)) {
             return;
         }
         $filecallback($file);
index 5539517..98ec900 100644 (file)
@@ -68,9 +68,16 @@ if ($errormsg !== '') {
 $showsettingslinks = true;
 
 if ($hassiteconfig) {
-    require_once("admin_settings_search_form.php");
-    $form = new admin_settings_search_form();
-    $form->display();
+    $data = [
+        'action' => new moodle_url('/admin/search.php'),
+        'btnclass' => 'btn-primary',
+        'inputname' => 'query',
+        'searchstring' => get_string('search'),
+        'query' => $query,
+        'extraclasses' => 'd-flex justify-content-center'
+    ];
+    echo $OUTPUT->render_from_template('core/search_input', $data);
+
     echo '<hr>';
     if ($query) {
         echo admin_search_settings_html($query);
index 504cbb9..5a4cc90 100644 (file)
@@ -1,21 +1,57 @@
 <?php
+// This file is part of Moodle - https://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
 
-if ($hassiteconfig) { // speedup for non-admins, add all caps used on this page
+/**
+ * Define administration settings on the Location settings page.
+ *
+ * @package     core
+ * @category    admin
+ * @copyright   2006 Martin Dougiamas <martin@moodle.com>
+ * @license     https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
 
-    // "locations" settingpage
-    $temp = new admin_settingpage('locationsettings', new lang_string('locationsettings', 'admin'));
-    $temp->add(new admin_setting_servertimezone());
-    $temp->add(new admin_setting_forcetimezone());
-    $temp->add(new admin_settings_country_select('country', new lang_string('country', 'admin'), new lang_string('configcountry', 'admin'), 0));
-    $temp->add(new admin_setting_configtext('defaultcity', new lang_string('defaultcity', 'admin'), new lang_string('defaultcity_help', 'admin'), ''));
+defined('MOODLE_INTERNAL') || die();
 
-    $temp->add(new admin_setting_heading('iplookup', new lang_string('iplookup', 'admin'), new lang_string('iplookupinfo', 'admin')));
-    $temp->add(new admin_setting_configfile('geoip2file', new lang_string('geoipfile', 'admin'),
-        new lang_string('configgeoipfile', 'admin', $CFG->dataroot.'/geoip/'), $CFG->dataroot.'/geoip/GeoLite2-City.mmdb'));
-    $temp->add(new admin_setting_configtext('googlemapkey3', new lang_string('googlemapkey3', 'admin'), new lang_string('googlemapkey3_help', 'admin'), '', PARAM_RAW, 60));
+if ($hassiteconfig) {
+    $temp = new admin_settingpage('locationsettings', new lang_string('locationsettings', 'core_admin'));
 
-    $temp->add(new admin_setting_configtext('allcountrycodes', new lang_string('allcountrycodes', 'admin'), new lang_string('configallcountrycodes', 'admin'), '', '/^(?:\w+(?:,\w+)*)?$/'));
+    if ($ADMIN->fulltree) {
+        $temp->add(new admin_setting_servertimezone());
 
-    $ADMIN->add('location', $temp);
+        $temp->add(new admin_setting_forcetimezone());
+
+        $temp->add(new admin_settings_country_select('country', new lang_string('country', 'core_admin'),
+            new lang_string('configcountry', 'core_admin'), 0));
+
+        $temp->add(new admin_setting_configtext('defaultcity', new lang_string('defaultcity', 'core_admin'),
+            new lang_string('defaultcity_help', 'core_admin'), ''));
+
+        $temp->add(new admin_setting_heading('iplookup', new lang_string('iplookup', 'core_admin'),
+            new lang_string('iplookupinfo', 'core_admin')));
 
-} // end of speedup
+        $temp->add(new admin_setting_configfile('geoip2file', new lang_string('geoipfile', 'core_admin'),
+            new lang_string('configgeoipfile', 'core_admin', $CFG->dataroot . '/geoip/'),
+            $CFG->dataroot . '/geoip/GeoLite2-City.mmdb'));
+
+        $temp->add(new admin_setting_configtext('googlemapkey3', new lang_string('googlemapkey3', 'core_admin'),
+            new lang_string('googlemapkey3_help', 'core_admin'), '', PARAM_RAW, 60));
+
+        $temp->add(new admin_setting_countrycodes('allcountrycodes', new lang_string('allcountrycodes', 'core_admin'),
+            new lang_string('configallcountrycodes', 'core_admin')));
+    }
+
+    $ADMIN->add('location', $temp);
+}
index 1c3ee18..cb586b4 100644 (file)
@@ -444,6 +444,17 @@ if ($hassiteconfig) {
         new lang_string('configallowedemaildomains', 'admin'),
         ''));
 
+    $temp->add(new admin_setting_heading('divertallemailsheading', new lang_string('divertallemails', 'admin'),
+        new lang_string('divertallemailsdetail', 'admin')));
+    $temp->add(new admin_setting_configtext('divertallemailsto',
+        new lang_string('divertallemailsto', 'admin'),
+        new lang_string('divertallemailsto_desc', 'admin'),
+        ''));
+    $temp->add(new admin_setting_configtextarea('divertallemailsexcept',
+        new lang_string('divertallemailsexcept', 'admin'),
+        new lang_string('divertallemailsexcept_desc', 'admin'),
+        '', PARAM_RAW, '50', '4'));
+
     $url = new moodle_url('/admin/testoutgoingmailconf.php');
     $link = html_writer::link($url, get_string('testoutgoingmailconf', 'admin'));
     $temp->add(new admin_setting_heading('testoutgoinmailc', new lang_string('testoutgoingmailconf', 'admin'),
index af027de..98f5d91 100644 (file)
@@ -56,7 +56,7 @@ class behat_admin extends behat_base {
             $this->execute('behat_navigation::i_select_from_flat_navigation_drawer', [get_string('administrationsite')]);
 
             // Search by label.
-            $this->execute('behat_forms::i_set_the_field_to', [get_string('query', 'admin'), $label]);
+            $this->execute('behat_forms::i_set_the_field_to', [get_string('search'), $label]);
             $this->execute("behat_forms::press_button", get_string('search', 'admin'));
 
             // Admin settings does not use the same DOM structure than other moodle forms
diff --git a/admin/tests/behat/invalid_allcountrycodes.feature b/admin/tests/behat/invalid_allcountrycodes.feature
new file mode 100644 (file)
index 0000000..399712f
--- /dev/null
@@ -0,0 +1,29 @@
+@core @core_admin
+Feature: Administrator is warned and when trying to set invalid allcountrycodes value.
+  In order to avoid misconfiguration of the country selector fields
+  As an admin
+  I want to be warned when I try to set an invalid country code in the allcountrycodes field
+
+  Scenario: Attempting to set allcountrycodes field with valid country codes
+    Given I log in as "admin"
+    And I navigate to "Location > Location settings" in site administration
+    When I set the following administration settings values:
+      | All country codes | CZ,BE,GB,ES |
+    Then I should not see "Invalid country code"
+
+  Scenario: Attempting to set allcountrycodes field with invalid country code
+    Given I log in as "admin"
+    And I navigate to "Location > Location settings" in site administration
+    When I set the following administration settings values:
+      | All country codes | CZ,BE,FOOBAR,GB,ES |
+    Then I should see "Invalid country code: FOOBAR"
+
+  Scenario: Attempting to unset allcountrycodes field
+    Given I log in as "admin"
+    And I navigate to "Location > Location settings" in site administration
+    And I set the following administration settings values:
+      | All country codes | CZ,BE,GB,ES |
+    And I navigate to "Location > Location settings" in site administration
+    When I set the following administration settings values:
+      | All country codes | |
+    Then I should not see "Invalid country code"
index 1c05534..ed3d79c 100644 (file)
Binary files a/admin/tool/capability/yui/build/moodle-tool_capability-search/moodle-tool_capability-search-debug.js and b/admin/tool/capability/yui/build/moodle-tool_capability-search/moodle-tool_capability-search-debug.js differ
index 6520ca2..30e97fc 100644 (file)
Binary files a/admin/tool/capability/yui/build/moodle-tool_capability-search/moodle-tool_capability-search-min.js and b/admin/tool/capability/yui/build/moodle-tool_capability-search/moodle-tool_capability-search-min.js differ
index 1c05534..ed3d79c 100644 (file)
Binary files a/admin/tool/capability/yui/build/moodle-tool_capability-search/moodle-tool_capability-search.js and b/admin/tool/capability/yui/build/moodle-tool_capability-search/moodle-tool_capability-search.js differ
index 1e4b96c..71bd152 100644 (file)
@@ -51,6 +51,13 @@ SEARCH.prototype = {
      * @protected
      */
     button: null,
+    /**
+     * The cancel button for the form.
+     * @property button
+     * @type Node
+     * @protected
+     */
+    cancel: null,
     /**
      * The last search node if there is one.
      * If there is a last search node then the last search term will be persisted between requests.
@@ -74,17 +81,28 @@ SEARCH.prototype = {
         this.button = this.form.all('input[type=submit]');
         this.lastsearch = this.form.one('input[name=search]');
 
-        var div = Y.Node.create('<div id="capabilitysearchui" data-fieldtype="text"></div>'),
-            label = Y.Node.create('<label for="capabilitysearch">' + this.get('strsearch') + '</label>');
-        this.input = Y.Node.create('<input type="text" id="capabilitysearch" />');
+        var div = Y.Node.create('<div id="capabilitysearchui" class="input-group simplesearchform mb-2"' +
+            'data-fieldtype="text"></div>'),
+            label = Y.Node.create('<label for="capabilitysearch"><span class="sr-only"' +
+                this.get('strsearch') + '</span></label>');
+        this.cancel = Y.Node.create('<a class="btn btn-clear d-none icon-no-margin">' +
+                '<i class="icon fa fa-times fa-fw " aria-hidden="true"></i>' +
+                '</a>');
+        this.input = Y.Node.create('<input type="text" class="form-control withclear" placeholder="' +
+            this.get('strsearch') + '"id="capabilitysearch" />');
 
-        div.append(label).append(this.input);
+        div.append(label).append(this.input).append(this.cancel);
 
         this.select.insert(div, 'before');
 
         this.input.on('keyup', this.typed, this);
         this.select.on('change', this.validate, this);
 
+        this.cancel.on('click', function() {
+            this.input.set('value', '');
+            this.typed();
+        }, this);
+
         if (this.lastsearch) {
             this.input.set('value', this.lastsearch.get('value'));
             this.typed();
@@ -131,6 +149,11 @@ SEARCH.prototype = {
                 last.set('selected', true);
             }
         }
+        if (search !== '') {
+            this.cancel.removeClass("d-none");
+        } else {
+            this.cancel.addClass("d-none");
+        }
         this.validate();
     }
 };
index 4c4e719..7306eb2 100644 (file)
@@ -1613,7 +1613,7 @@ class external extends external_api {
      */
     private static function get_tree_node_structure($allowchildbranches = true) {
         $fields = [
-            'text' => new external_value(PARAM_TEXT, 'The node text', VALUE_REQUIRED),
+            'text' => new external_value(PARAM_RAW, 'The node text', VALUE_REQUIRED),
             'expandcontextid' => new external_value(PARAM_INT, 'The contextid this node expands', VALUE_REQUIRED),
             'expandelement' => new external_value(PARAM_ALPHA, 'What element is this node expanded to', VALUE_REQUIRED),
             'contextid' => new external_value(PARAM_INT, 'The node contextid', VALUE_REQUIRED),
index 341bc8a..8032906 100644 (file)
@@ -63,7 +63,6 @@ class renderer extends plugin_renderer_base {
         $params = [
             'data-action' => 'contactdpo',
             'data-replytoemail' => $replytoemail,
-            'class' => 'contactdpo'
         ];
         return html_writer::link('#', get_string('contactdataprotectionofficer', 'tool_dataprivacy'), $params);
     }
index f37f62a..eb0eaba 100644 (file)
@@ -142,7 +142,7 @@ function tool_dbtransfer_get_drivers() {
         $dblibrary = $matches[2];
 
         if ($dbtype === 'sqlite3') {
-            // Blacklist unfinished drivers.
+            // The sqlite3 driver is not fully working yet and should not be returned.
             continue;
         }
 
index 006d917..9ea41b0 100644 (file)
@@ -323,6 +323,12 @@ class api {
             }
         }
 
+        if (empty($section) or $section == 'supportcontact') {
+            $settings->supportname = $CFG->supportname;
+            $settings->supportemail = $CFG->supportemail;
+            $settings->supportpage = $CFG->supportpage;
+        }
+
         return $settings;
     }
 
@@ -488,6 +494,7 @@ class api {
                 '$mmSideMenuDelegate_mmaCompetency' => new lang_string('myplans', 'tool_lp'),
                 'CoreMainMenuDelegate_AddonBlog' => new lang_string('blog', 'blog'),
                 '$mmSideMenuDelegate_mmaFiles' => new lang_string('files'),
+                'CoreMainMenuDelegate_CoreTag' => new lang_string('tags'),
                 '$mmSideMenuDelegate_website' => new lang_string('webpage'),
                 '$mmSideMenuDelegate_help' => new lang_string('help'),
                 'CoreMainMenuDelegate_QrReader' => new lang_string('scanqrcode', 'tool_mobile'),
index b3c56bb..8b9057c 100644 (file)
@@ -139,7 +139,7 @@ class external extends external_api {
             array(
                 'wwwroot' => new external_value(PARAM_RAW, 'Site URL.'),
                 'httpswwwroot' => new external_value(PARAM_RAW, 'Site https URL (if httpslogin is enabled).'),
-                'sitename' => new external_value(PARAM_TEXT, 'Site name.'),
+                'sitename' => new external_value(PARAM_RAW, 'Site name.'),
                 'guestlogin' => new external_value(PARAM_INT, 'Whether guest login is enabled.'),
                 'rememberusername' => new external_value(PARAM_INT, 'Values: 0 for No, 1 for Yes, 2 for optional.'),
                 'authloginviaemail' => new external_value(PARAM_INT, 'Whether log in via email is enabled.'),
diff --git a/admin/tool/mobile/lang/en/deprecated.txt b/admin/tool/mobile/lang/en/deprecated.txt
new file mode 100644 (file)
index 0000000..0edc136
--- /dev/null
@@ -0,0 +1 @@
+mobileappconnected,tool_mobile
\ No newline at end of file
index 678a1b5..6fafc5f 100644 (file)
@@ -88,7 +88,6 @@ $string['managefiletypes'] = 'Manage file types';
 $string['minimumversion'] = 'If an app version is specified (3.8.0 or higher), any users using an older app version will be prompted to upgrade their app before being allowed access to the site.';
 $string['minimumversion_key'] = 'Minimum app version required';
 $string['mobileapp'] = 'Mobile app';
-$string['mobileappconnected'] = 'Mobile app connected';
 $string['mobileappenabled'] = 'This site has mobile app access enabled.<br /><a href="{$a}">Download the mobile app</a>.';
 $string['mobileappearance'] = 'Mobile appearance';
 $string['mobileappsubscription'] = 'Moodle app subscription';
@@ -144,3 +143,6 @@ $string['privacy:metadata:preference:tool_mobile_autologin_request_last'] = 'The
 $string['privacy:metadata:core_userkey'] = 'User\'s keys used to create auto-login key for the current user.';
 $string['responsivemainmenuitems'] = 'Responsive menu items';
 $string['viewqrcode'] = 'View QR code';
+
+// Deprecated since Moodle 3.10.
+$string['mobileappconnected'] = 'Mobile app connected';
index 43d6cc2..567af8b 100644 (file)
@@ -87,22 +87,34 @@ function tool_mobile_create_app_download_url() {
 }
 
 /**
- * Checks if the given user has a mobile token (has used recently the app).
+ * Return the user mobile app WebService access token.
  *
- * @param  int $userid the user to check
- * @return bool        true if the user has a token, false otherwise.
+ * @param  int $userid the user to return the token from
+ * @return stdClass|false the token or false if the token doesn't exists
+ * @since  3.10
  */
-function tool_mobile_user_has_token($userid) {
+function tool_mobile_get_token($userid) {
     global $DB;
 
-    $sql = "SELECT 1
+    $sql = "SELECT t.*
               FROM {external_tokens} t, {external_services} s
              WHERE t.externalserviceid = s.id
                AND s.enabled = 1
                AND s.shortname IN ('moodle_mobile_app', 'local_mobile')
                AND t.userid = ?";
 
-    return $DB->record_exists_sql($sql, [$userid]);
+    return $DB->get_record_sql($sql, [$userid], IGNORE_MULTIPLE);
+}
+
+/**
+ * Checks if the given user has a mobile token (has used recently the app).
+ *
+ * @param  int $userid the user to check
+ * @return bool true if the user has a token, false otherwise.
+ */
+function tool_mobile_user_has_token($userid) {
+
+    return !empty(tool_mobile_get_token($userid));
 }
 
 /**
@@ -162,17 +174,25 @@ function tool_mobile_myprofile_navigation(\core_user\output\myprofile\tree $tree
     }
 
     // Check if the user is using the app, encouraging him to use it otherwise.
-    $userhastoken = tool_mobile_user_has_token($user->id);
+    $usertoken = tool_mobile_get_token($user->id);
     $mobilestrconnected = null;
-
-    if ($userhastoken) {
-        $mobilestrconnected = get_string('mobileappconnected', 'tool_mobile');
+    $mobilelastaccess = null;
+
+    if ($usertoken) {
+        $mobilestrconnected = get_string('lastsiteaccess');
+        if ($usertoken->lastaccess) {
+            $mobilelastaccess = userdate($usertoken->lastaccess) . "&nbsp; (" . format_time(time() - $usertoken->lastaccess) . ")";
+        } else {
+            // We should not reach this point.
+            $mobilelastaccess = get_string("never");
+        }
     } else if ($url = tool_mobile_create_app_download_url()) {
          $mobilestrconnected = get_string('mobileappenabled', 'tool_mobile', $url->out());
     }
 
     if ($mobilestrconnected) {
-        $newnodes[] = new core_user\output\myprofile\node('mobile', 'mobileappnode', $mobilestrconnected, null);
+        $newnodes[] = new core_user\output\myprofile\node('mobile', 'mobileappnode', $mobilestrconnected, null, null,
+            $mobilelastaccess);
     }
 
     // Add nodes, if any.
index 1a16eb7..0909aaf 100644 (file)
@@ -230,6 +230,10 @@ class tool_mobile_external_testcase extends externallib_advanced_testcase {
                 'value' => get_config('core_admin', 'coursecolor' . $number)
             ];
         }
+        $expected[] = ['name' => 'supportname', 'value' => $CFG->supportname];
+        $expected[] = ['name' => 'supportemail', 'value' => $CFG->supportemail];
+        $expected[] = ['name' => 'supportpage', 'value' => $CFG->supportpage];
+
         $this->assertCount(0, $result['warnings']);
         $this->assertEquals($expected, $result['settings']);
 
index 5c60510..d42fbe2 100644 (file)
@@ -46,6 +46,13 @@ class tool_replace_form extends moodleform {
         $mform->addElement('text', 'replace', get_string('replacewith', 'tool_replace'), 'size="50"', PARAM_RAW);
         $mform->addElement('static', 'replacest', '', get_string('replacewithhelp', 'tool_replace'));
         $mform->setType('replace', PARAM_RAW);
+
+        $mform->addElement('textarea', 'additionalskiptables', get_string("additionalskiptables", "tool_replace"),
+            array('rows' => 5, 'cols' => 50));
+        $mform->addElement('static', 'additionalskiptables_desc', '', get_string('additionalskiptables_desc', 'tool_replace'));
+        $mform->setType('additionalskiptables', PARAM_RAW);
+        $mform->setDefault('additionalskiptables', '');
+
         $mform->addElement('checkbox', 'shorten', get_string('shortenoversized', 'tool_replace'));
         $mform->addRule('replace', get_string('required'), 'required', null, 'client');
 
index 1f9e74b..b4244aa 100644 (file)
@@ -34,6 +34,7 @@ $help =
 Options:
 --search=STRING       String to search for.
 --replace=STRING      String to replace with.
+--skiptables=STRING   Skip these tables (comma separated list of tables).
 --shorten             Shorten result if necessary.
 --non-interactive     Perform the replacement without confirming.
 -h, --help            Print out this help.
@@ -46,6 +47,7 @@ list($options, $unrecognized) = cli_get_params(
     array(
         'search'  => null,
         'replace' => null,
+        'skiptables' => '',
         'shorten' => false,
         'non-interactive' => false,
         'help'    => false,
@@ -71,6 +73,7 @@ if (empty($options['shorten']) && core_text::strlen($options['search']) < core_t
 try {
     $search = validate_param($options['search'], PARAM_RAW);
     $replace = validate_param($options['replace'], PARAM_RAW);
+    $skiptables = validate_param($options['skiptables'], PARAM_RAW);
 } catch (invalid_parameter_exception $e) {
     cli_error(get_string('invalidcharacter', 'tool_replace'));
 }
@@ -85,7 +88,7 @@ if (!$options['non-interactive']) {
     }
 }
 
-if (!db_replace($search, $replace)) {
+if (!db_replace($search, $replace, $skiptables)) {
     cli_heading(get_string('error'));
     exit(1);
 }
index b3013d2..e4f2900 100644 (file)
@@ -57,7 +57,7 @@ if (!$data = $form->get_data()) {
 $PAGE->requires->js_init_code("window.scrollTo(0, 5000000);");
 
 echo $OUTPUT->box_start();
-db_replace($data->search, $data->replace);
+db_replace($data->search, $data->replace, $data->additionalskiptables);
 echo $OUTPUT->box_end();
 
 // Course caches are now rebuilt on the fly.
index 6117521..e8dce48 100644 (file)
@@ -22,7 +22,8 @@
  * @copyright  2011 Petr Skoda {@link http://skodak.org}
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
-
+$string['additionalskiptables'] = 'Additional skip tables';
+$string['additionalskiptables_desc'] = 'Please specify the additional tables (comma separated list) you want to skip while running DB search and replace.';
 $string['cannotfit'] = 'The replacement is longer than the original and shortening is not allowed; cannot continue.';
 $string['disclaimer'] = 'I understand the risks of this operation';
 $string['doit'] = 'Yes, do it!';
index ea1aac5..74f8c50 100644 (file)
Binary files a/admin/tool/templatelibrary/amd/build/search.min.js and b/admin/tool/templatelibrary/amd/build/search.min.js differ
index 1611c7e..0245bb8 100644 (file)
Binary files a/admin/tool/templatelibrary/amd/build/search.min.js.map and b/admin/tool/templatelibrary/amd/build/search.min.js.map differ
index 18623df..9caf344 100644 (file)
@@ -45,8 +45,13 @@ define(['jquery', 'core/ajax', 'core/log', 'core/notification', 'core/templates'
      */
     var refreshSearch = function(themename) {
         var componentStr = $('[data-field="component"]').val();
-        var searchStr = $('[data-field="search"]').val();
+        var searchStr = $('[data-region="list-templates"] [data-region="input"]').val();
 
+        if (searchStr !== '') {
+            $('[data-region="list-templates"] [data-action="clearsearch"]').removeClass('d-none');
+        } else {
+            $('[data-region="list-templates"] [data-action="clearsearch"]').addClass('d-none');
+        }
         // Trigger the search.
         document.location.hash = searchStr;
 
@@ -84,9 +89,14 @@ define(['jquery', 'core/ajax', 'core/log', 'core/notification', 'core/templates'
     };
     // Add change handlers to refresh the list.
     $('[data-region="list-templates"]').on('change', '[data-field="component"]', changeHandler);
-    $('[data-region="list-templates"]').on('input', '[data-field="search"]', changeHandler);
+    $('[data-region="list-templates"]').on('input', '[data-region="input"]', changeHandler);
+    $('[data-action="clearsearch"]').on('click', function() {
+        $('[data-region="input"]').val('');
+        refreshSearch(config.theme);
+        $(this).addClass('d-none');
+    });
 
-    $('[data-field="search"]').val(document.location.hash.replace('#', ''));
+    $('[data-region="input"]').val(document.location.hash.replace('#', ''));
     refreshSearch(config.theme);
     return {};
 });
index 0526c94..c5a454e 100644 (file)
     Context variables required for this template:
     * allcomponents - array of components containing templates. Each component has a name and a component attribute.
 
+}}
+{{!
+    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_templatelibrary/list_templates_page
+
+    Moodle template to the template library
+
+    The purpose of this template is build the entire page for the template library (by including smaller templates).
+
+    Classes required for JS:
+    * none
+
+    Data attributes required for JS:
+    * data-region, data-field
+
+    Context variables required for this template:
+    * allcomponents - array of components containing templates. Each component has a name and a component attribute.
+
 }}
 <div data-region="list-templates">
     <form class="form-horizontal">
-        <div class="control-group">
-            <label for="selectcomponent" class="control-label">{{#str}}component, tool_templatelibrary{{/str}}</label>
-            <div class="controls">
-                <select id="selectcomponent" data-field="component">
-                    <option value="">{{#str}}all, tool_templatelibrary{{/str}}</option>
-                    {{#allcomponents}}
-                        <option value="{{component}}">{{name}}</option>
-                    {{/allcomponents}}
-                </select>
-            </div>
-        </div>
-        <div class="control-group">
-            <label for="search" class="control-label">{{#str}}search, tool_templatelibrary{{/str}}</label>
-            <div class="controls">
-                <input type="text" id="search" data-field="search"/>
-            </div>
-        </div>
+    {{< core_form/element-template }}
+        {{$label}}
+            <div class="col-form-label">{{#str}}component, tool_templatelibrary{{/str}}</div>
+        {{/label}}
+
+        {{$element}}
+            <select id="selectcomponent" class="form-control" data-field="component">
+                <option value="">{{#str}}all, tool_templatelibrary{{/str}}</option>
+                {{#allcomponents}}
+                    <option value="{{component}}">{{name}}</option>
+                {{/allcomponents}}
+            </select>
+        {{/element}}
+    {{/ core_form/element-template }}
+
+    {{< core_form/element-template }}
+        {{$element}}
+            {{< core/search_input_auto }}
+                {{$label}}{{{ searchstring }}}{{/label}}
+                {{$placeholder}}{{#str}}
+                    search, core
+                {{/str}}{{/placeholder}}
+            {{/ core/search_input_auto }}
+        {{/element}}
+    {{/ core_form/element-template }}
     </form>
     <hr/>
     {{> tool_templatelibrary/search_results }}
diff --git a/admin/tool/uploaduser/classes/cli_helper.php b/admin/tool/uploaduser/classes/cli_helper.php
new file mode 100644 (file)
index 0000000..e258e7d
--- /dev/null
@@ -0,0 +1,402 @@
+<?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/>.
+
+/**
+ * Class cli_helper
+ *
+ * @package     tool_uploaduser
+ * @copyright   2020 Marina Glancy
+ * @license     http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace tool_uploaduser;
+
+defined('MOODLE_INTERNAL') || die();
+
+use tool_uploaduser\local\cli_progress_tracker;
+
+require_once($CFG->dirroot.'/user/profile/lib.php');
+require_once($CFG->dirroot.'/user/lib.php');
+require_once($CFG->dirroot.'/group/lib.php');
+require_once($CFG->dirroot.'/cohort/lib.php');
+require_once($CFG->libdir.'/csvlib.class.php');
+require_once($CFG->dirroot.'/'.$CFG->admin.'/tool/uploaduser/locallib.php');
+require_once($CFG->dirroot.'/'.$CFG->admin.'/tool/uploaduser/user_form.php');
+require_once($CFG->libdir . '/clilib.php');
+
+/**
+ * Helper method for CLI script to upload users (also has special wrappers for cli* functions for phpunit testing)
+ *
+ * @package     tool_uploaduser
+ * @copyright   2020 Marina Glancy
+ * @license     http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class cli_helper {
+
+    /** @var string */
+    protected $operation;
+    /** @var array */
+    protected $clioptions;
+    /** @var array */
+    protected $unrecognized;
+    /** @var string */
+    protected $progresstrackerclass;
+
+    /** @var process */
+    protected $process;
+
+    /**
+     * cli_helper constructor.
+     *
+     * @param string|null $progresstrackerclass
+     */
+    public function __construct(?string $progresstrackerclass = null) {
+        $this->progresstrackerclass = $progresstrackerclass ?? cli_progress_tracker::class;
+        $optionsdefinitions = $this->options_definitions();
+        $longoptions = [];
+        $shortmapping = [];
+        foreach ($optionsdefinitions as $key => $option) {
+            $longoptions[$key] = $option['default'];
+            if (!empty($option['alias'])) {
+                $shortmapping[$option['alias']] = $key;
+            }
+        }
+
+        list($this->clioptions, $this->unrecognized) = cli_get_params(
+            $longoptions,
+            $shortmapping
+        );
+    }
+
+    /**
+     * Options used in this CLI script
+     *
+     * @return array
+     */
+    protected function options_definitions(): array {
+        $options = [
+            'help' => [
+                'hasvalue' => false,
+                'description' => get_string('clihelp', 'tool_uploaduser'),
+                'default' => 0,
+                'alias' => 'h',
+            ],
+            'file' => [
+                'hasvalue' => 'PATH',
+                'description' => get_string('clifile', 'tool_uploaduser'),
+                'default' => null,
+                'validation' => function($file) {
+                    if (!$file) {
+                        $this->cli_error(get_string('climissingargument', 'tool_uploaduser', 'file'));
+                    }
+                    if ($file && (!file_exists($file) || !is_readable($file))) {
+                        $this->cli_error(get_string('clifilenotreadable', 'tool_uploaduser', $file));
+                    }
+                }
+            ],
+        ];
+        $form = new \admin_uploaduser_form1();
+        [$elements, $defaults] = $form->get_form_for_cli();
+        $options += $this->prepare_form_elements_for_cli($elements, $defaults);
+        $form = new \admin_uploaduser_form2(null, ['columns' => ['type1'], 'data' => []]);
+        [$elements, $defaults] = $form->get_form_for_cli();
+        $options += $this->prepare_form_elements_for_cli($elements, $defaults);
+        return $options;
+    }
+
+    /**
+     * Print help for export
+     */
+    public function print_help(): void {
+        $this->cli_writeln(get_string('clititle', 'tool_uploaduser'));
+        $this->cli_writeln('');
+        $this->print_help_options($this->options_definitions());
+        $this->cli_writeln('');
+        $this->cli_writeln('Example:');
+        $this->cli_writeln('$sudo -u www-data /usr/bin/php admin/tool/uploaduser/cli/uploaduser.php --file=PATH');
+    }
+
+    /**
+     * Get CLI option
+     *
+     * @param string $key
+     * @return mixed|null
+     */
+    public function get_cli_option(string $key) {
+        return $this->clioptions[$key] ?? null;
+    }
+
+    /**
+     * Write a text to the given stream
+     *
+     * @param string $text text to be written
+     */
+    protected function cli_write($text): void {
+        if (PHPUNIT_TEST) {
+            echo $text;
+        } else {
+            cli_write($text);
+        }
+    }
+
+    /**
+     * Write error notification
+     * @param string $text
+     * @return void
+     */
+    protected function cli_problem($text): void {
+        if (PHPUNIT_TEST) {
+            echo $text;
+        } else {
+            cli_problem($text);
+        }
+    }
+
+    /**
+     * Write a text followed by an end of line symbol to the given stream
+     *
+     * @param string $text text to be written
+     */
+    protected function cli_writeln($text): void {
+        $this->cli_write($text . PHP_EOL);
+    }
+
+    /**
+     * Write to standard error output and exit with the given code
+     *
+     * @param string $text
+     * @param int $errorcode
+     * @return void (does not return)
+     */
+    protected function cli_error($text, $errorcode = 1): void {
+        $this->cli_problem($text);
+        $this->die($errorcode);
+    }
+
+    /**
+     * Wrapper for "die()" method so we can unittest it
+     *
+     * @param mixed $errorcode
+     * @throws \moodle_exception
+     */
+    protected function die($errorcode): void {
+        if (!PHPUNIT_TEST) {
+            die($errorcode);
+        } else {
+            throw new \moodle_exception('CLI script finished with error code '.$errorcode);
+        }
+    }
+
+    /**
+     * Display as CLI table
+     *
+     * @param array $column1
+     * @param array $column2
+     * @param int $indent
+     * @return string
+     */
+    protected function convert_to_table(array $column1, array $column2, int $indent = 0): string {
+        $maxlengthleft = 0;
+        $left = [];
+        $column1 = array_values($column1);
+        $column2 = array_values($column2);
+        foreach ($column1 as $i => $l) {
+            $left[$i] = str_repeat(' ', $indent) . $l;
+            if (strlen('' . $column2[$i])) {
+                $maxlengthleft = max($maxlengthleft, strlen($l) + $indent);
+            }
+        }
+        $maxlengthright = 80 - $maxlengthleft - 1;
+        $output = '';
+        foreach ($column2 as $i => $r) {
+            if (!strlen('' . $r)) {
+                $output .= $left[$i] . "\n";
+                continue;
+            }
+            $right = wordwrap($r, $maxlengthright, "\n");
+            $output .= str_pad($left[$i], $maxlengthleft) . ' ' .
+                str_replace("\n", PHP_EOL . str_repeat(' ', $maxlengthleft + 1), $right) . PHP_EOL;
+        }
+        return $output;
+    }
+
+    /**
+     * Display available CLI options as a table
+     *
+     * @param array $options
+     */
+    protected function print_help_options(array $options): void {
+        $left = [];
+        $right = [];
+        foreach ($options as $key => $option) {
+            if ($option['hasvalue'] !== false) {
+                $l = "--$key={$option['hasvalue']}";
+            } else if (!empty($option['alias'])) {
+                $l = "-{$option['alias']}, --$key";
+            } else {
+                $l = "--$key";
+            }
+            $left[] = $l;
+            $right[] = $option['description'];
+        }
+        $this->cli_write('Options:' . PHP_EOL . $this->convert_to_table($left, $right));
+    }
+
+    /**
+     * Process the upload
+     */
+    public function process(): void {
+        // First, validate all arguments.
+        $definitions = $this->options_definitions();
+        foreach ($this->clioptions as $key => $value) {
+            if ($validator = $definitions[$key]['validation'] ?? null) {
+                $validator($value);
+            }
+        }
+
+        // Read the CSV file.
+        $iid = \csv_import_reader::get_new_iid('uploaduser');
+        $cir = new \csv_import_reader($iid, 'uploaduser');
+        $cir->load_csv_content(file_get_contents($this->get_cli_option('file')),
+            $this->get_cli_option('encoding'), $this->get_cli_option('delimiter_name'));
+        $csvloaderror = $cir->get_error();
+
+        if (!is_null($csvloaderror)) {
+            $this->cli_error(get_string('csvloaderror', 'error', $csvloaderror), 1);
+        }
+
+        // Start upload user process.
+        $this->process = new \tool_uploaduser\process($cir, $this->progresstrackerclass);
+        $filecolumns = $this->process->get_file_columns();
+
+        $form = $this->mock_form(['columns' => $filecolumns, 'data' => ['iid' => $iid, 'previewrows' => 1]], $this->clioptions);
+
+        if (!$form->is_validated()) {
+            $errors = $form->get_validation_errors();
+            $this->cli_error(get_string('clivalidationerror', 'tool_uploaduser') . PHP_EOL .
+                $this->convert_to_table(array_keys($errors), array_values($errors), 2));
+        }
+
+        $this->process->set_form_data($form->get_data());
+        $this->process->process();
+    }
+
+    /**
+     * Mock form submission
+     *
+     * @param array $customdata
+     * @param array $submitteddata
+     * @return \admin_uploaduser_form2
+     */
+    protected function mock_form(array $customdata, array $submitteddata): \admin_uploaduser_form2 {
+        global $USER;
+        $submitteddata['description'] = ['text' => $submitteddata['description'], 'format' => FORMAT_HTML];
+
+        // Now mock the form submission.
+        $submitteddata['_qf__admin_uploaduser_form2'] = 1;
+        $oldignoresesskey = $USER->ignoresesskey ?? null;
+        $USER->ignoresesskey = true;
+        $form = new \admin_uploaduser_form2(null, $customdata, 'post', '', [], true, $submitteddata);
+        $USER->ignoresesskey = $oldignoresesskey;
+
+        $form->set_data($submitteddata);
+        return $form;
+    }
+
+    /**
+     * Prepare form elements for CLI
+     *
+     * @param \HTML_QuickForm_element[] $elements
+     * @param array $defaults
+     * @return array
+     */
+    protected function prepare_form_elements_for_cli(array $elements, array $defaults): array {
+        $options = [];
+        foreach ($elements as $element) {
+            if ($element instanceof \HTML_QuickForm_submit || $element instanceof \HTML_QuickForm_static) {
+                continue;
+            }
+            $type = $element->getType();
+            if ($type === 'html' || $type === 'hidden' || $type === 'header') {
+                continue;
+            }
+
+            $name = $element->getName();
+            if ($name === null || preg_match('/^mform_isexpanded_/', $name)
+                || preg_match('/^_qf__/', $name)) {
+                continue;
+            }
+
+            $label = $element->getLabel();
+            if (!strlen($label) && method_exists($element, 'getText')) {
+                $label = $element->getText();
+            }
+            $default = $defaults[$element->getName()] ?? null;
+
+            $postfix = '';
+            $possiblevalues = null;
+            if ($element instanceof \HTML_QuickForm_select) {
+                $selectoptions = $element->_options;
+                $possiblevalues = [];
+                foreach ($selectoptions as $option) {
+                    $possiblevalues[] = '' . $option['attr']['value'];
+                }
+                if (count($selectoptions) < 10) {
+                    $postfix .= ':';
+                    foreach ($selectoptions as $option) {
+                        $postfix .= "\n  ".$option['attr']['value']." - ".$option['text'];
+                    }
+                }
+                if (!array_key_exists($name, $defaults)) {
+                    $firstoption = reset($selectoptions);
+                    $default = $firstoption['attr']['value'];
+                }
+            }
+
+            if ($element instanceof \HTML_QuickForm_checkbox) {
+                $postfix = ":\n  0|1";
+                $possiblevalues = ['0', '1'];
+            }
+
+            if ($default !== null & $default !== '') {
+                $postfix .= "\n  ".get_string('clidefault', 'tool_uploaduser')." ".$default;
+            }
+            $options[$name] = [
+                'hasvalue' => 'VALUE',
+                'description' => $label.$postfix,
+                'default' => $default,
+            ];
+            if ($possiblevalues !== null) {
+                $options[$name]['validation'] = function($v) use ($possiblevalues, $name) {
+                    if (!in_array('' . $v, $possiblevalues)) {
+                        $this->cli_error(get_string('clierrorargument', 'tool_uploaduser',
+                            (object)['name' => $name, 'values' => join(', ', $possiblevalues)]));
+                    }
+                };
+            }
+        }
+        return $options;
+    }
+
+    /**
+     * Get process statistics.
+     *
+     * @return array
+     */
+    public function get_stats(): array {
+        return $this->process->get_stats();
+    }
+}
diff --git a/admin/tool/uploaduser/classes/local/cli_progress_tracker.php b/admin/tool/uploaduser/classes/local/cli_progress_tracker.php
new file mode 100644 (file)
index 0000000..39c5529
--- /dev/null
@@ -0,0 +1,43 @@
+<?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/>.
+
+/**
+ * Class cli_progress_tracker
+ *
+ * @package     tool_uploaduser
+ * @copyright   2020 Marina Glancy
+ * @license     http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace tool_uploaduser\local;
+
+/**
+ * Tracks the progress of the user upload and outputs it in CLI script (writes to STDOUT)
+ *
+ * @package     tool_uploaduser
+ * @copyright   2020 Marina Glancy
+ * @license     http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class cli_progress_tracker extends text_progress_tracker {
+
+    /**
+     * Output one line (followed by newline)
+     * @param string $line
+     */
+    protected function output_line(string $line): void {
+        cli_writeln($line);
+    }
+}
diff --git a/admin/tool/uploaduser/classes/local/text_progress_tracker.php b/admin/tool/uploaduser/classes/local/text_progress_tracker.php
new file mode 100644 (file)
index 0000000..9e14640
--- /dev/null
@@ -0,0 +1,124 @@
+<?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/>.
+
+/**
+ * Class text_progress_tracker
+ *
+ * @package     tool_uploaduser
+ * @copyright   2020 Marina Glancy
+ * @license     http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace tool_uploaduser\local;
+
+/**
+ * Tracks the progress of the user upload and echos it in a text format
+ *
+ * @package     tool_uploaduser
+ * @copyright   2020 Marina Glancy
+ * @license     http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class text_progress_tracker extends \uu_progress_tracker {
+
+    /**
+     * Print table header.
+     * @return void
+     */
+    public function start() {
+        $this->_row = null;
+    }
+
+    /**
+     * Output one line (followed by newline)
+     * @param string $line
+     */
+    protected function output_line(string $line): void {
+        echo $line . PHP_EOL;
+    }
+
+    /**
+     * Flush previous line and start a new one.
+     * @return void
+     */
+    public function flush() {
+        if (empty($this->_row) or empty($this->_row['line']['normal'])) {
+            // Nothing to print - each line has to have at least number.
+            $this->_row = array();
+            foreach ($this->columns as $col) {
+                $this->_row[$col] = ['normal' => '', 'info' => '', 'warning' => '', 'error' => ''];
+            }
+            return;
+        }
+        $this->output_line(get_string('linex', 'tool_uploaduser', $this->_row['line']['normal']));
+        $prefix = [
+            'normal' => '',
+            'info' => '',
+            'warning' => get_string('warningprefix', 'tool_uploaduser') . ' ',
+            'error' => get_string('errorprefix', 'tool_uploaduser') . ' ',
+        ];
+        foreach ($this->_row['status'] as $type => $content) {
+            if (strlen($content)) {
+                $this->output_line('  '.$prefix[$type].$content);
+            }
+        }
+
+        foreach ($this->_row as $key => $field) {
+            foreach ($field as $type => $content) {
+                if ($key !== 'status' && $type !== 'normal' && strlen($content)) {
+                    $this->output_line('  ' . $prefix[$type] . $this->headers[$key] . ': ' .
+                        str_replace("\n", "\n".str_repeat(" ", strlen($prefix[$type] . $this->headers[$key]) + 4), $content));
+                }
+            }
+        }
+        foreach ($this->columns as $col) {
+            $this->_row[$col] = ['normal' => '', 'info' => '', 'warning' => '', 'error' => ''];
+        }
+    }
+
+    /**
+     * Add tracking info
+     * @param string $col name of column
+     * @param string $msg message
+     * @param string $level 'normal', 'warning' or 'error'
+     * @param bool $merge true means add as new line, false means override all previous text of the same type
+     * @return void
+     */
+    public function track($col, $msg, $level = 'normal', $merge = true) {
+        if (empty($this->_row)) {
+            $this->flush();
+        }
+        if (!in_array($col, $this->columns)) {
+            return;
+        }
+        if ($merge) {
+            if ($this->_row[$col][$level] != '') {
+                $this->_row[$col][$level] .= "\n";
+            }
+            $this->_row[$col][$level] .= $msg;
+        } else {
+            $this->_row[$col][$level] = $msg;
+        }
+    }
+
+    /**
+     * Print the table end
+     * @return void
+     */
+    public function close() {
+        $this->flush();
+        $this->output_line(str_repeat('-', 79));
+    }
+}
diff --git a/admin/tool/uploaduser/classes/preview.php b/admin/tool/uploaduser/classes/preview.php
new file mode 100644 (file)
index 0000000..a593024
--- /dev/null
@@ -0,0 +1,158 @@
+<?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/>.
+
+/**
+ * Class preview
+ *
+ * @package     tool_uploaduser
+ * @copyright   2020 Marina Glancy
+ * @license     http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace tool_uploaduser;
+
+defined('MOODLE_INTERNAL') || die();
+
+use tool_uploaduser\local\field_value_validators;
+
+require_once($CFG->libdir.'/csvlib.class.php');
+require_once($CFG->dirroot.'/'.$CFG->admin.'/tool/uploaduser/locallib.php');
+
+/**
+ * Display the preview of a CSV file
+ *
+ * @package     tool_uploaduser
+ * @copyright   2020 Marina Glancy
+ * @license     http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class preview extends \html_table {
+
+    /** @var \csv_import_reader  */
+    protected $cir;
+    /** @var array */
+    protected $filecolumns;
+    /** @var int */
+    protected $previewrows;
+    /** @var bool */
+    protected $noerror = true; // Keep status of any error.
+
+    /**
+     * preview constructor.
+     *
+     * @param \csv_import_reader $cir
+     * @param array $filecolumns
+     * @param int $previewrows
+     * @throws \coding_exception
+     */
+    public function __construct(\csv_import_reader $cir, array $filecolumns, int $previewrows) {
+        parent::__construct();
+        $this->cir = $cir;
+        $this->filecolumns = $filecolumns;
+        $this->previewrows = $previewrows;
+
+        $this->id = "uupreview";
+        $this->attributes['class'] = 'generaltable';
+        $this->tablealign = 'center';
+        $this->summary = get_string('uploaduserspreview', 'tool_uploaduser');
+        $this->head = array();
+        $this->data = $this->read_data();
+
+        $this->head[] = get_string('uucsvline', 'tool_uploaduser');
+        foreach ($filecolumns as $column) {
+            $this->head[] = $column;
+        }
+        $this->head[] = get_string('status');
+
+    }
+
+    /**
+     * Read data
+     *
+     * @return array
+     * @throws \coding_exception
+     * @throws \dml_exception
+     * @throws \moodle_exception
+     */
+    protected function read_data() {
+        global $DB, $CFG;
+
+        $data = array();
+        $this->cir->init();
+        $linenum = 1; // Column header is first line.
+        while ($linenum <= $this->previewrows and $fields = $this->cir->next()) {
+            $linenum++;
+            $rowcols = array();
+            $rowcols['line'] = $linenum;
+            foreach ($fields as $key => $field) {
+                $rowcols[$this->filecolumns[$key]] = s(trim($field));
+            }
+            $rowcols['status'] = array();
+
+            if (isset($rowcols['username'])) {
+                $stdusername = \core_user::clean_field($rowcols['username'], 'username');
+                if ($rowcols['username'] !== $stdusername) {
+                    $rowcols['status'][] = get_string('invalidusernameupload');
+                }
+                if ($userid = $DB->get_field('user', 'id',
+                        ['username' => $stdusername, 'mnethostid' => $CFG->mnet_localhost_id])) {
+                    $rowcols['username'] = \html_writer::link(
+                        new \moodle_url('/user/profile.php', ['id' => $userid]), $rowcols['username']);
+                }
+            } else {
+                $rowcols['status'][] = get_string('missingusername');
+            }
+
+            if (isset($rowcols['email'])) {
+                if (!validate_email($rowcols['email'])) {
+                    $rowcols['status'][] = get_string('invalidemail');
+                }
+
+                $select = $DB->sql_like('email', ':email', false, true, false, '|');
+                $params = array('email' => $DB->sql_like_escape($rowcols['email'], '|'));
+                if ($DB->record_exists_select('user', $select , $params)) {
+                    $rowcols['status'][] = get_string('useremailduplicate', 'error');
+                }
+            }
+
+            if (isset($rowcols['theme'])) {
+                list($status, $message) = field_value_validators::validate_theme($rowcols['theme']);
+                if ($status !== 'normal' && !empty($message)) {
+                    $rowcols['status'][] = $message;
+                }
+            }
+
+            // Check if rowcols have custom profile field with correct data and update error state.
+            $this->noerror = uu_check_custom_profile_data($rowcols) && $this->noerror;
+            $rowcols['status'] = implode('<br />', $rowcols['status']);
+            $data[] = $rowcols;
+        }
+        if ($fields = $this->cir->next()) {
+            $data[] = array_fill(0, count($fields) + 2, '...');
+        }
+        $this->cir->close();
+
+        return $data;
+    }
+
+    /**
+     * Getter for noerror
+     *
+     * @return bool
+     */
+    public function get_no_error() {
+        return $this->noerror;
+    }
+}
\ No newline at end of file
diff --git a/admin/tool/uploaduser/classes/process.php b/admin/tool/uploaduser/classes/process.php
new file mode 100644 (file)
index 0000000..12c2101
--- /dev/null
@@ -0,0 +1,1327 @@
+<?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/>.
+
+/**
+ * Class process
+ *
+ * @package     tool_uploaduser
+ * @copyright   2020 Moodle
+ * @license     http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace tool_uploaduser;
+
+defined('MOODLE_INTERNAL') || die();
+
+use tool_uploaduser\local\field_value_validators;
+
+require_once($CFG->dirroot.'/user/profile/lib.php');
+require_once($CFG->dirroot.'/user/lib.php');
+require_once($CFG->dirroot.'/group/lib.php');
+require_once($CFG->dirroot.'/cohort/lib.php');
+require_once($CFG->libdir.'/csvlib.class.php');
+require_once($CFG->dirroot.'/'.$CFG->admin.'/tool/uploaduser/locallib.php');
+
+/**
+ * Process CSV file with users data, this will create/update users, enrol them into courses, etc
+ *
+ * @package     tool_uploaduser
+ * @copyright   2020 Moodle
+ * @license     http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class process {
+
+    /** @var \csv_import_reader  */
+    protected $cir;
+    /** @var \stdClass  */
+    protected $formdata;
+    /** @var \uu_progress_tracker  */
+    protected $upt;
+    /** @var array  */
+    protected $filecolumns = null;
+    /** @var int  */
+    protected $today;
+    /** @var \enrol_plugin|null */
+    protected $manualenrol = null;
+    /** @var array */
+    protected $standardfields = [];
+    /** @var array */
+    protected $profilefields = [];
+    /** @var array */
+    protected $allprofilefields = [];
+    /** @var string|\uu_progress_tracker|null  */
+    protected $progresstrackerclass = null;
+
+    /** @var int */
+    protected $usersnew      = 0;
+    /** @var int */
+    protected $usersupdated  = 0;
+    /** @var int /not printed yet anywhere */
+    protected $usersuptodate = 0;
+    /** @var int */
+    protected $userserrors   = 0;
+    /** @var int */
+    protected $deletes       = 0;
+    /** @var int */
+    protected $deleteerrors  = 0;
+    /** @var int */
+    protected $renames       = 0;
+    /** @var int */
+    protected $renameerrors  = 0;
+    /** @var int */
+    protected $usersskipped  = 0;
+    /** @var int */
+    protected $weakpasswords = 0;
+
+    /** @var array course cache - do not fetch all courses here, we  will not probably use them all anyway */
+    protected $ccache         = [];
+    /** @var array */
+    protected $cohorts        = [];
+    /** @var array  Course roles lookup cache. */
+    protected $rolecache      = [];
+    /** @var array System roles lookup cache. */
+    protected $sysrolecache   = [];
+    /** @var array cache of used manual enrol plugins in each course */
+    protected $manualcache    = [];
+    /** @var array officially supported plugins that are enabled */
+    protected $supportedauths = [];
+
+    /**
+     * process constructor.
+     *
+     * @param \csv_import_reader $cir
+     * @param string|null $progresstrackerclass
+     * @throws \coding_exception
+     */
+    public function __construct(\csv_import_reader $cir, string $progresstrackerclass = null) {
+        $this->cir = $cir;
+        if ($progresstrackerclass) {
+            if (!class_exists($progresstrackerclass) || !is_subclass_of($progresstrackerclass, \uu_progress_tracker::class)) {
+                throw new \coding_exception('Progress tracker class must extend \uu_progress_tracker');
+            }
+            $this->progresstrackerclass = $progresstrackerclass;
+        } else {
+            $this->progresstrackerclass = \uu_progress_tracker::class;
+        }
+
+        // Keep timestamp consistent.
+        $today = time();
+        $today = make_timestamp(date('Y', $today), date('m', $today), date('d', $today), 0, 0, 0);
+        $this->today = $today;
+
+        $this->rolecache      = uu_allowed_roles_cache(); // Course roles lookup cache.
+        $this->sysrolecache   = uu_allowed_sysroles_cache(); // System roles lookup cache.
+        $this->supportedauths = uu_supported_auths(); // Officially supported plugins that are enabled.
+
+        if (enrol_is_enabled('manual')) {
+            // We use only manual enrol plugin here, if it is disabled no enrol is done.
+            $this->manualenrol = enrol_get_plugin('manual');
+        }
+
+        $this->find_profile_fields();
+        $this->find_standard_fields();
+    }
+
+    /**
+     * Standard user fields.
+     */
+    protected function find_standard_fields(): void {
+        $this->standardfields = array('id', 'username', 'email', 'emailstop',
+            'city', 'country', 'lang', 'timezone', 'mailformat',
+            'maildisplay', 'maildigest', 'htmleditor', 'autosubscribe',
+            'institution', 'department', 'idnumber', 'skype',
+            'msn', 'aim', 'yahoo', 'icq', 'phone1', 'phone2', 'address',
+            'url', 'description', 'descriptionformat', 'password',
+            'auth',        // Watch out when changing auth type or using external auth plugins!
+            'oldusername', // Use when renaming users - this is the original username.
+            'suspended',   // 1 means suspend user account, 0 means activate user account, nothing means keep as is.
+            'theme',       // Define a theme for user when 'allowuserthemes' is enabled.
+            'deleted',     // 1 means delete user
+            'mnethostid',  // Can not be used for adding, updating or deleting of users - only for enrolments,
+                           // groups, cohorts and suspending.
+            'interests',
+        );
+        // Include all name fields.
+        $this->standardfields = array_merge($this->standardfields, get_all_user_name_fields());
+    }
+
+    /**
+     * Profile fields
+     */
+    protected function find_profile_fields(): void {
+        global $DB;
+        $this->allprofilefields = $DB->get_records('user_info_field');
+        $this->profilefields = [];
+        if ($proffields = $this->allprofilefields) {
+            foreach ($proffields as $key => $proffield) {
+                $profilefieldname = 'profile_field_'.$proffield->shortname;
+                $this->profilefields[] = $profilefieldname;
+                // Re-index $proffields with key as shortname. This will be
+                // used while checking if profile data is key and needs to be converted (eg. menu profile field).
+                $proffields[$profilefieldname] = $proffield;
+                unset($proffields[$key]);
+            }
+            $this->allprofilefields = $proffields;
+        }
+    }
+
+    /**
+     * Returns the list of columns in the file
+     *
+     * @return array
+     */
+    public function get_file_columns(): array {
+        if ($this->filecolumns === null) {
+            $returnurl = new \moodle_url('/admin/tool/uploaduser/index.php');
+            $this->filecolumns = uu_validate_user_upload_columns($this->cir,
+                $this->standardfields, $this->profilefields, $returnurl);
+        }
+        return $this->filecolumns;
+    }
+
+    /**
+     * Set data from the form (or from CLI options)
+     *
+     * @param \stdClass $formdata
+     */
+    public function set_form_data(\stdClass $formdata): void {
+        global $SESSION;
+        $this->formdata = $formdata;
+
+        // Clear bulk selection.
+        if ($this->get_bulk()) {
+            $SESSION->bulk_users = array();
+        }
+    }
+
+    /**
+     * Operation type
+     * @return int
+     */
+    protected function get_operation_type(): int {
+        return (int)$this->formdata->uutype;
+    }
+
+    /**
+     * Setting to allow deletes
+     * @return bool
+     */
+    protected function get_allow_deletes(): bool {
+        $optype = $this->get_operation_type();
+        return (!empty($this->formdata->uuallowdeletes) and $optype != UU_USER_ADDNEW and $optype != UU_USER_ADDINC);
+    }
+
+    /**
+     * Setting to allow deletes
+     * @return bool
+     */
+    protected function get_allow_renames(): bool {
+        $optype = $this->get_operation_type();
+        return (!empty($this->formdata->uuallowrenames) and $optype != UU_USER_ADDNEW and $optype != UU_USER_ADDINC);
+    }
+
+    /**
+     * Setting to select for bulk actions (not available in CLI)
+     * @return bool
+     */
+    public function get_bulk(): bool {
+        return $this->formdata->uubulk ?? false;
+    }
+
+    /**
+     * Setting for update type
+     * @return int
+     */
+    protected function get_update_type(): int {
+        return isset($this->formdata->uuupdatetype) ? $this->formdata->uuupdatetype : 0;
+    }
+
+    /**
+     * Setting to allow update passwords
+     * @return bool
+     */
+    protected function get_update_passwords(): bool {
+        return !empty($this->formdata->uupasswordold)
+            and $this->get_operation_type() != UU_USER_ADDNEW
+            and $this->get_operation_type() != UU_USER_ADDINC
+            and ($this->get_update_type() == UU_UPDATE_FILEOVERRIDE or $this->get_update_type() == UU_UPDATE_ALLOVERRIDE);
+    }
+
+    /**
+     * Setting to allow email duplicates
+     * @return bool
+     */
+    protected function get_allow_email_duplicates(): bool {
+        global $CFG;
+        return !(empty($CFG->allowaccountssameemail) ? 1 : $this->formdata->uunoemailduplicates);
+    }
+
+    /**
+     * Setting for reset password
+     * @return int UU_PWRESET_NONE, UU_PWRESET_WEAK, UU_PWRESET_ALL
+     */
+    protected function get_reset_passwords(): int {
+        return isset($this->formdata->uuforcepasswordchange) ? $this->formdata->uuforcepasswordchange : UU_PWRESET_NONE;
+    }
+
+    /**
+     * Setting to allow create passwords
+     * @return bool
+     */
+    protected function get_create_paswords(): bool {
+        return (!empty($this->formdata->uupasswordnew) and $this->get_operation_type() != UU_USER_UPDATE);
+    }
+
+    /**
+     * Setting to allow suspends
+     * @return bool
+     */
+    protected function get_allow_suspends(): bool {
+        return !empty($this->formdata->uuallowsuspends);
+    }
+
+    /**
+     * Setting to normalise user names
+     * @return bool
+     */
+    protected function get_normalise_user_names(): bool {
+        return !empty($this->formdata->uustandardusernames);
+    }
+
+    /**
+     * Helper method to return Yes/No string
+     *
+     * @param bool $value
+     * @return string
+     */
+    protected function get_string_yes_no($value): string {
+        return $value ? get_string('yes') : get_string('no');
+    }
+
+    /**
+     * Process the CSV file
+     */
+    public function process() {
+        // Init csv import helper.
+        $this->cir->init();
+
+        $classname = $this->progresstrackerclass;
+        $this->upt = new $classname();
+        $this->upt->start(); // Start table.
+
+        $linenum = 1; // Column header is first line.
+        while ($line = $this->cir->next()) {
+            $this->upt->flush();
+            $linenum++;
+
+            $this->upt->track('line', $linenum);
+            $this->process_line($line);
+        }
+
+        $this->upt->close(); // Close table.
+        $this->cir->close();
+        $this->cir->cleanup(true);
+    }
+
+    /**
+     * Prepare one line from CSV file as a user record
+     *
+     * @param array $line
+     * @return \stdClass|null
+     */
+    protected function prepare_user_record(array $line): ?\stdClass {
+        global $CFG, $USER;
+
+        $user = new \stdClass();
+
+        // Add fields to user object.
+        foreach ($line as $keynum => $value) {
+            if (!isset($this->get_file_columns()[$keynum])) {
+                // This should not happen.
+                continue;
+            }
+            $key = $this->get_file_columns()[$keynum];
+            if (strpos($key, 'profile_field_') === 0) {
+                // NOTE: bloody mega hack alert!!
+                if (isset($USER->$key) and is_array($USER->$key)) {
+                    // This must be some hacky field that is abusing arrays to store content and format.
+                    $user->$key = array();
+                    $user->{$key['text']}   = $value;
+                    $user->{$key['format']} = FORMAT_MOODLE;
+                } else {
+                    $user->$key = trim($value);
+                }
+            } else {
+                $user->$key = trim($value);
+            }
+
+            if (in_array($key, $this->upt->columns)) {
+                // Default value in progress tracking table, can be changed later.
+                $this->upt->track($key, s($value), 'normal');
+            }
+        }
+        if (!isset($user->username)) {
+            // Prevent warnings below.
+            $user->username = '';
+        }
+
+        if ($this->get_operation_type() == UU_USER_ADDNEW or $this->get_operation_type() == UU_USER_ADDINC) {
+            // User creation is a special case - the username may be constructed from templates using firstname and lastname
+            // better never try this in mixed update types.
+            $error = false;
+            if (!isset($user->firstname) or $user->firstname === '') {
+                $this->upt->track('status', get_string('missingfield', 'error', 'firstname'), 'error');
+                $this->upt->track('firstname', get_string('error'), 'error');
+                $error = true;
+            }
+            if (!isset($user->lastname) or $user->lastname === '') {
+                $this->upt->track('status', get_string('missingfield', 'error', 'lastname'), 'error');
+                $this->upt->track('lastname', get_string('error'), 'error');
+                $error = true;
+            }
+            if ($error) {
+                $this->userserrors++;
+                return null;
+            }
+            // We require username too - we might use template for it though.
+            if (empty($user->username) and !empty($this->formdata->username)) {
+                $user->username = uu_process_template($this->formdata->username, $user);
+                $this->upt->track('username', s($user->username));
+            }
+        }
+
+        // Normalize username.
+        $user->originalusername = $user->username;
+        if ($this->get_normalise_user_names()) {
+            $user->username = \core_user::clean_field($user->username, 'username');
+        }
+
+        // Make sure we really have username.
+        if (empty($user->username)) {
+            $this->upt->track('status', get_string('missingfield', 'error', 'username'), 'error');
+            $this->upt->track('username', get_string('error'), 'error');
+            $this->userserrors++;
+            return null;
+        } else if ($user->username === 'guest') {
+            $this->upt->track('status', get_string('guestnoeditprofileother', 'error'), 'error');
+            $this->userserrors++;
+            return null;
+        }
+
+        if ($user->username !== \core_user::clean_field($user->username, 'username')) {
+            $this->upt->track('status', get_string('invalidusername', 'error', 'username'), 'error');
+            $this->upt->track('username', get_string('error'), 'error');
+            $this->userserrors++;
+        }
+
+        if (empty($user->mnethostid)) {
+            $user->mnethostid = $CFG->mnet_localhost_id;
+        }
+
+        return $user;
+    }
+
+    /**
+     * Process one line from CSV file
+     *
+     * @param array $line
+     * @throws \coding_exception
+     * @throws \dml_exception
+     * @throws \moodle_exception
+     */
+    public function process_line(array $line) {
+        global $DB, $CFG, $SESSION;
+
+        if (!$user = $this->prepare_user_record($line)) {
+            return;
+        }
+
+        if ($existinguser = $DB->get_record('user', ['username' => $user->username, 'mnethostid' => $user->mnethostid])) {
+            $this->upt->track('id', $existinguser->id, 'normal', false);
+        }
+
+        if ($user->mnethostid == $CFG->mnet_localhost_id) {
+            $remoteuser = false;
+
+            // Find out if username incrementing required.
+            if ($existinguser and $this->get_operation_type() == UU_USER_ADDINC) {
+                $user->username = uu_increment_username($user->username);
+                $existinguser = false;
+            }
+
+        } else {
+            if (!$existinguser or $this->get_operation_type() == UU_USER_ADDINC) {
+                $this->upt->track('status', get_string('errormnetadd', 'tool_uploaduser'), 'error');
+                $this->userserrors++;
+                return;
+            }
+
+            $remoteuser = true;
+
+            // Make sure there are no changes of existing fields except the suspended status.
+            foreach ((array)$existinguser as $k => $v) {
+                if ($k === 'suspended') {
+                    continue;
+                }
+                if (property_exists($user, $k)) {
+                    $user->$k = $v;
+                }
+                if (in_array($k, $this->upt->columns)) {
+                    if ($k === 'password' or $k === 'oldusername' or $k === 'deleted') {
+                        $this->upt->track($k, '', 'normal', false);
+                    } else {
+                        $this->upt->track($k, s($v), 'normal', false);
+                    }
+                }
+            }
+            unset($user->oldusername);
+            unset($user->password);
+            $user->auth = $existinguser->auth;
+        }
+
+        // Notify about nay username changes.
+        if ($user->originalusername !== $user->username) {
+            $this->upt->track('username', '', 'normal', false); // Clear previous.
+            $this->upt->track('username', s($user->originalusername).'-->'.s($user->username), 'info');
+        } else {
+            $this->upt->track('username', s($user->username), 'normal', false);
+        }
+        unset($user->originalusername);
+
+        // Verify if the theme is valid and allowed to be set.
+        if (isset($user->theme)) {
+            list($status, $message) = field_value_validators::validate_theme($user->theme);
+            if ($status !== 'normal' && !empty($message)) {
+                $this->upt->track('status', $message, $status);
+                // Unset the theme when validation fails.
+                unset($user->theme);
+            }
+        }
+
+        // Add default values for remaining fields.
+        $formdefaults = array();
+        if (!$existinguser ||
+                ($this->get_update_type() != UU_UPDATE_FILEOVERRIDE && $this->get_update_type() != UU_UPDATE_NOCHANGES)) {
+            foreach ($this->standardfields as $field) {
+                if (isset($user->$field)) {
+                    continue;
+                }
+                // All validation moved to form2.
+                if (isset($this->formdata->$field)) {
+                    // Process templates.
+                    $user->$field = uu_process_template($this->formdata->$field, $user);
+                    $formdefaults[$field] = true;
+                    if (in_array($field, $this->upt->columns)) {
+                        $this->upt->track($field, s($user->$field), 'normal');
+                    }
+                }
+            }
+            $proffields = $this->allprofilefields;
+            foreach ($this->profilefields as $field) {
+                if (isset($user->$field)) {
+                    continue;
+                }
+                if (isset($this->formdata->$field)) {
+                    // Process templates.
+                    $user->$field = uu_process_template($this->formdata->$field, $user);
+
+                    // Form contains key and later code expects value.
+                    // Convert key to value for required profile fields.
+                    require_once($CFG->dirroot.'/user/profile/field/'.$proffields[$field]->datatype.'/field.class.php');
+                    $profilefieldclass = 'profile_field_'.$proffields[$field]->datatype;
+                    $profilefield = new $profilefieldclass($proffields[$field]->id);
+                    if (method_exists($profilefield, 'convert_external_data')) {
+                        $user->$field = $profilefield->edit_save_data_preprocess($user->$field, null);
+                    }
+
+                    $formdefaults[$field] = true;
+                }
+            }
+        }
+
+        // Delete user.
+        if (!empty($user->deleted)) {
+            if (!$this->get_allow_deletes() or $remoteuser) {
+                $this->usersskipped++;
+                $this->upt->track('status', get_string('usernotdeletedoff', 'error'), 'warning');
+                return;
+            }
+            if ($existinguser) {
+                if (is_siteadmin($existinguser->id)) {
+                    $this->upt->track('status', get_string('usernotdeletedadmin', 'error'), 'error');
+                    $this->deleteerrors++;
+                    return;
+                }
+                if (delete_user($existinguser)) {
+                    $this->upt->track('status', get_string('userdeleted', 'tool_uploaduser'));
+                    $this->deletes++;
+                } else {
+                    $this->upt->track('status', get_string('usernotdeletederror', 'error'), 'error');
+                    $this->deleteerrors++;
+                }
+            } else {
+                $this->upt->track('status', get_string('usernotdeletedmissing', 'error'), 'error');
+                $this->deleteerrors++;
+            }
+            return;
+        }
+        // We do not need the deleted flag anymore.
+        unset($user->deleted);
+
+        // Renaming requested?
+        if (!empty($user->oldusername) ) {
+            if (!$this->get_allow_renames()) {
+                $this->usersskipped++;
+                $this->upt->track('status', get_string('usernotrenamedoff', 'error'), 'warning');
+                return;
+            }
+
+            if ($existinguser) {
+                $this->upt->track('status', get_string('usernotrenamedexists', 'error'), 'error');
+                $this->renameerrors++;
+                return;
+            }
+
+            if ($user->username === 'guest') {
+                $this->upt->track('status', get_string('guestnoeditprofileother', 'error'), 'error');
+                $this->renameerrors++;
+                return;
+            }
+
+            if ($this->get_normalise_user_names()) {
+                $oldusername = \core_user::clean_field($user->oldusername, 'username');
+            } else {
+                $oldusername = $user->oldusername;
+            }
+
+            // No guessing when looking for old username, it must be exact match.
+            if ($olduser = $DB->get_record('user',
+                    ['username' => $oldusername, 'mnethostid' => $CFG->mnet_localhost_id])) {
+                $this->upt->track('id', $olduser->id, 'normal', false);
+                if (is_siteadmin($olduser->id)) {
+                    $this->upt->track('status', get_string('usernotrenamedadmin', 'error'), 'error');
+                    $this->renameerrors++;
+                    return;
+                }
+                $DB->set_field('user', 'username', $user->username, ['id' => $olduser->id]);
+                $this->upt->track('username', '', 'normal', false); // Clear previous.
+                $this->upt->track('username', s($oldusername).'-->'.s($user->username), 'info');
+                $this->upt->track('status', get_string('userrenamed', 'tool_uploaduser'));
+                $this->renames++;
+            } else {
+                $this->upt->track('status', get_string('usernotrenamedmissing', 'error'), 'error');
+                $this->renameerrors++;
+                return;
+            }
+            $existinguser = $olduser;
+            $existinguser->username = $user->username;
+        }
+
+        // Can we process with update or insert?
+        $skip = false;
+        switch ($this->get_operation_type()) {
+            case UU_USER_ADDNEW:
+                if ($existinguser) {
+                    $this->usersskipped++;
+                    $this->upt->track('status', get_string('usernotaddedregistered', 'error'), 'warning');
+                    $skip = true;
+                }
+                break;
+
+            case UU_USER_ADDINC:
+                if ($existinguser) {
+                    // This should not happen!
+                    $this->upt->track('status', get_string('usernotaddederror', 'error'), 'error');
+                    $this->userserrors++;
+                    $skip = true;
+                }
+                break;
+
+            case UU_USER_ADD_UPDATE:
+                break;
+
+            case UU_USER_UPDATE:
+                if (!$existinguser) {
+                    $this->usersskipped++;
+                    $this->upt->track('status', get_string('usernotupdatednotexists', 'error'), 'warning');
+                    $skip = true;
+                }
+                break;
+
+            default:
+                // Unknown type.
+                $skip = true;
+        }
+
+        if ($skip) {
+            return;
+        }
+
+        if ($existinguser) {
+            $user->id = $existinguser->id;
+
+            $this->upt->track('username', \html_writer::link(
+                new \moodle_url('/user/profile.php', ['id' => $existinguser->id]), s($existinguser->username)), 'normal', false);
+            $this->upt->track('suspended', $this->get_string_yes_no($existinguser->suspended) , 'normal', false);
+            $this->upt->track('auth', $existinguser->auth, 'normal', false);
+
+            if (is_siteadmin($user->id)) {
+                $this->upt->track('status', get_string('usernotupdatedadmin', 'error'), 'error');
+                $this->userserrors++;
+                return;
+            }
+
+            $existinguser->timemodified = time();
+            // Do NOT mess with timecreated or firstaccess here!
+
+            // Load existing profile data.
+            profile_load_data($existinguser);
+
+            $doupdate = false;
+            $dologout = false;
+
+            if ($this->get_update_type() != UU_UPDATE_NOCHANGES and !$remoteuser) {
+                if (!empty($user->auth) and $user->auth !== $existinguser->auth) {
+                    $this->upt->track('auth', s($existinguser->auth).'-->'.s($user->auth), 'info', false);
+                    $existinguser->auth = $user->auth;
+                    if (!isset($this->supportedauths[$user->auth])) {
+                        $this->upt->track('auth', get_string('userauthunsupported', 'error'), 'warning');
+                    }
+                    $doupdate = true;
+                    if ($existinguser->auth === 'nologin') {
+                        $dologout = true;
+                    }
+                }
+                $allcolumns = array_merge($this->standardfields, $this->profilefields);
+                foreach ($allcolumns as $column) {
+                    if ($column === 'username' or $column === 'password' or $column === 'auth' or $column === 'suspended') {
+                        // These can not be changed here.
+                        continue;
+                    }
+                    if (!property_exists($user, $column) or !property_exists($existinguser, $column)) {
+                        continue;
+                    }
+                    if ($this->get_update_type() == UU_UPDATE_MISSING) {
+                        if (!is_null($existinguser->$column) and $existinguser->$column !== '') {
+                            continue;
+                        }
+                    } else if ($this->get_update_type() == UU_UPDATE_ALLOVERRIDE) {
+                        // We override everything.
+                        null;
+                    } else if ($this->get_update_type() == UU_UPDATE_FILEOVERRIDE) {
+                        if (!empty($formdefaults[$column])) {
+                            // Do not override with form defaults.
+                            continue;
+                        }
+                    }
+                    if ($existinguser->$column !== $user->$column) {
+                        if ($column === 'email') {
+                            $select = $DB->sql_like('email', ':email', false, true, false, '|');
+                            $params = array('email' => $DB->sql_like_escape($user->email, '|'));
+                            if ($DB->record_exists_select('user', $select , $params)) {
+
+                                $changeincase = \core_text::strtolower($existinguser->$column) === \core_text::strtolower(
+                                        $user->$column);
+
+                                if ($changeincase) {
+                                    // If only case is different then switch to lower case and carry on.
+                                    $user->$column = \core_text::strtolower($user->$column);
+                                    continue;
+                                } else if (!$this->get_allow_email_duplicates()) {
+                                    $this->upt->track('email', get_string('useremailduplicate', 'error'), 'error');
+                                    $this->upt->track('status', get_string('usernotupdatederror', 'error'), 'error');
+                                    $this->userserrors++;
+                                    return;
+                                } else {
+                                    $this->upt->track('email', get_string('useremailduplicate', 'error'), 'warning');
+                                }
+                            }
+                            if (!validate_email($user->email)) {
+                                $this->upt->track('email', get_string('invalidemail'), 'warning');
+                            }
+                        }
+
+                        if ($column === 'lang') {
+                            if (empty($user->lang)) {
+                                // Do not change to not-set value.
+                                continue;
+                            } else if (\core_user::clean_field($user->lang, 'lang') === '') {
+                                $this->upt->track('status', get_string('cannotfindlang', 'error', $user->lang), 'warning');
+                                continue;
+                            }
+                        }
+
+                        if (in_array($column, $this->upt->columns)) {
+                            $this->upt->track($column, s($existinguser->$column).'-->'.s($user->$column), 'info', false);
+                        }
+                        $existinguser->$column = $user->$column;
+                        $doupdate = true;
+                    }
+                }
+            }
+
+            try {
+                $auth = get_auth_plugin($existinguser->auth);
+            } catch (\Exception $e) {
+                $this->upt->track('auth', get_string('userautherror', 'error', s($existinguser->auth)), 'error');
+                $this->upt->track('status', get_string('usernotupdatederror', 'error'), 'error');
+                $this->userserrors++;
+                return;
+            }
+            $isinternalauth = $auth->is_internal();
+
+            // Deal with suspending and activating of accounts.
+            if ($this->get_allow_suspends() and isset($user->suspended) and $user->suspended !== '') {
+                $user->suspended = $user->suspended ? 1 : 0;
+                if ($existinguser->suspended != $user->suspended) {
+                    $this->upt->track('suspended', '', 'normal', false);
+                    $this->upt->track('suspended',
+                        $this->get_string_yes_no($existinguser->suspended).'-->'.$this->get_string_yes_no($user->suspended),
+                        'info', false);
+                    $existinguser->suspended = $user->suspended;
+                    $doupdate = true;
+                    if ($existinguser->suspended) {
+                        $dologout = true;
+                    }
+                }
+            }
+
+            // Changing of passwords is a special case
+            // do not force password changes for external auth plugins!
+            $oldpw = $existinguser->password;
+
+            if ($remoteuser) {
+                // Do not mess with passwords of remote users.
+                null;
+            } else if (!$isinternalauth) {
+                $existinguser->password = AUTH_PASSWORD_NOT_CACHED;
+                $this->upt->track('password', '-', 'normal', false);
+                // Clean up prefs.
+                unset_user_preference('create_password', $existinguser);
+                unset_user_preference('auth_forcepasswordchange', $existinguser);
+
+            } else if (!empty($user->password)) {
+                if ($this->get_update_passwords()) {
+                    // Check for passwords that we want to force users to reset next
+                    // time they log in.
+                    $errmsg = null;
+                    $weak = !check_password_policy($user->password, $errmsg, $user);
+                    if ($this->get_reset_passwords() == UU_PWRESET_ALL or
+                            ($this->get_reset_passwords() == UU_PWRESET_WEAK and $weak)) {
+                        if ($weak) {
+                            $this->weakpasswords++;
+                            $this->upt->track('password', get_string('invalidpasswordpolicy', 'error'), 'warning');
+                        }
+                        set_user_preference('auth_forcepasswordchange', 1, $existinguser);
+                    } else {
+                        unset_user_preference('auth_forcepasswordchange', $existinguser);
+                    }
+                    unset_user_preference('create_password', $existinguser); // No need to create password any more.
+
+                    // Use a low cost factor when generating bcrypt hash otherwise
+                    // hashing would be slow when uploading lots of users. Hashes
+                    // will be automatically updated to a higher cost factor the first
+                    // time the user logs in.
+                    $existinguser->password = hash_internal_user_password($user->password, true);
+                    $this->upt->track('password', $user->password, 'normal', false);
+                } else {
+                    // Do not print password when not changed.
+                    $this->upt->track('password', '', 'normal', false);
+                }
+            }
+
+            if ($doupdate or $existinguser->password !== $oldpw) {
+                // We want only users that were really updated.
+                user_update_user($existinguser, false, false);
+
+                $this->upt->track('status', get_string('useraccountupdated', 'tool_uploaduser'));
+                $this->usersupdated++;
+
+                if (!$remoteuser) {
+                    // Pre-process custom profile menu fields data from csv file.
+                    $existinguser = uu_pre_process_custom_profile_data($existinguser);
+                    // Save custom profile fields data from csv file.
+                    profile_save_data($existinguser);
+                }
+
+                if ($this->get_bulk() == UU_BULK_UPDATED or $this->get_bulk() == UU_BULK_ALL) {
+                    if (!in_array($user->id, $SESSION->bulk_users)) {
+                        $SESSION->bulk_users[] = $user->id;
+                    }
+                }
+
+                // Trigger event.
+                \core\event\user_updated::create_from_userid($existinguser->id)->trigger();
+
+            } else {
+                // No user information changed.
+                $this->upt->track('status', get_string('useraccountuptodate', 'tool_uploaduser'));
+                $this->usersuptodate++;
+
+                if ($this->get_bulk() == UU_BULK_ALL) {
+                    if (!in_array($user->id, $SESSION->bulk_users)) {
+                        $SESSION->bulk_users[] = $user->id;
+                    }
+                }
+            }
+
+            if ($dologout) {
+                \core\session\manager::kill_user_sessions($existinguser->id);
+            }
+
+        } else {
+            // Save the new user to the database.
+            $user->confirmed    = 1;
+            $user->timemodified = time();
+            $user->timecreated  = time();
+            $user->mnethostid   = $CFG->mnet_localhost_id; // We support ONLY local accounts here, sorry.
+
+            if (!isset($user->suspended) or $user->suspended === '') {
+                $user->suspended = 0;
+            } else {
+                $user->suspended = $user->suspended ? 1 : 0;
+            }
+            $this->upt->track('suspended', $this->get_string_yes_no($user->suspended), 'normal', false);
+
+            if (empty($user->auth)) {
+                $user->auth = 'manual';
+            }
+            $this->upt->track('auth', $user->auth, 'normal', false);
+
+            // Do not insert record if new auth plugin does not exist!
+            try {
+                $auth = get_auth_plugin($user->auth);
+            } catch (\Exception $e) {
+                $this->upt->track('auth', get_string('userautherror', 'error', s($user->auth)), 'error');
+                $this->upt->track('status', get_string('usernotaddederror', 'error'), 'error');
+                $this->userserrors++;
+                return;
+            }
+            if (!isset($this->supportedauths[$user->auth])) {
+                $this->upt->track('auth', get_string('userauthunsupported', 'error'), 'warning');
+            }
+
+            $isinternalauth = $auth->is_internal();
+
+            if (empty($user->email)) {
+                $this->upt->track('email', get_string('invalidemail'), 'error');
+                $this->upt->track('status', get_string('usernotaddederror', 'error'), 'error');
+                $this->userserrors++;
+                return;
+
+            } else if ($DB->record_exists('user', ['email' => $user->email])) {
+                if (!$this->get_allow_email_duplicates()) {
+                    $this->upt->track('email', get_string('useremailduplicate', 'error'), 'error');
+                    $this->upt->track('status', get_string('usernotaddederror', 'error'), 'error');
+                    $this->userserrors++;
+                    return;
+                } else {
+                    $this->upt->track('email', get_string('useremailduplicate', 'error'), 'warning');
+                }
+            }
+            if (!validate_email($user->email)) {
+                $this->upt->track('email', get_string('invalidemail'), 'warning');
+            }
+
+            if (empty($user->lang)) {
+                $user->lang = '';
+            } else if (\core_user::clean_field($user->lang, 'lang') === '') {
+                $this->upt->track('status', get_string('cannotfindlang', 'error', $user->lang), 'warning');
+                $user->lang = '';
+            }
+
+            $forcechangepassword = false;
+
+            if ($isinternalauth) {
+                if (empty($user->password)) {
+                    if ($this->get_create_paswords()) {
+                        $user->password = 'to be generated';
+                        $this->upt->track('password', '', 'normal', false);
+                        $this->upt->track('password', get_string('uupasswordcron', 'tool_uploaduser'), 'warning', false);
+                    } else {
+                        $this->upt->track('password', '', 'normal', false);
+                        $this->upt->track('password', get_string('missingfield', 'error', 'password'), 'error');
+                        $this->upt->track('status', get_string('usernotaddederror', 'error'), 'error');
+                        $this->userserrors++;
+                        return;
+                    }
+                } else {
+                    $errmsg = null;
+                    $weak = !check_password_policy($user->password, $errmsg, $user);
+                    if ($this->get_reset_passwords() == UU_PWRESET_ALL or
+                            ($this->get_reset_passwords() == UU_PWRESET_WEAK and $weak)) {
+                        if ($weak) {
+                            $this->weakpasswords++;
+                            $this->upt->track('password', get_string('invalidpasswordpolicy', 'error'), 'warning');
+                        }
+                        $forcechangepassword = true;
+                    }
+                    // Use a low cost factor when generating bcrypt hash otherwise
+                    // hashing would be slow when uploading lots of users. Hashes
+                    // will be automatically updated to a higher cost factor the first
+                    // time the user logs in.
+                    $user->password = hash_internal_user_password($user->password, true);
+                }
+            } else {
+                $user->password = AUTH_PASSWORD_NOT_CACHED;
+                $this->upt->track('password', '-', 'normal', false);
+            }
+
+            $user->id = user_create_user($user, false, false);
+            $this->upt->track('username', \html_writer::link(
+                new \moodle_url('/user/profile.php', ['id' => $user->id]), s($user->username)), 'normal', false);
+
+            // Pre-process custom profile menu fields data from csv file.
+            $user = uu_pre_process_custom_profile_data($user);
+            // Save custom profile fields data.
+            profile_save_data($user);
+
+            if ($forcechangepassword) {
+                set_user_preference('auth_forcepasswordchange', 1, $user);
+            }
+            if ($user->password === 'to be generated') {
+                set_user_preference('create_password', 1, $user);
+            }
+
+            // Trigger event.
+            \core\event\user_created::create_from_userid($user->id)->trigger();
+
+            $this->upt->track('status', get_string('newuser'));
+            $this->upt->track('id', $user->id, 'normal', false);
+            $this->usersnew++;
+
+            // Make sure user context exists.
+            \context_user::instance($user->id);
+
+            if ($this->get_bulk() == UU_BULK_NEW or $this->get_bulk() == UU_BULK_ALL) {
+                if (!in_array($user->id, $SESSION->bulk_users)) {
+                    $SESSION->bulk_users[] = $user->id;
+                }
+            }
+        }
+
+        // Update user interests.
+        if (isset($user->interests) && strval($user->interests) !== '') {
+            useredit_update_interests($user, preg_split('/\s*,\s*/', $user->interests, -1, PREG_SPLIT_NO_EMPTY));
+        }
+
+        // Add to cohort first, it might trigger enrolments indirectly - do NOT create cohorts here!
+        foreach ($this->get_file_columns() as $column) {
+            if (!preg_match('/^cohort\d+$/', $column)) {
+                continue;
+            }
+
+            if (!empty($user->$column)) {
+                $addcohort = $user->$column;
+                if (!isset($this->cohorts[$addcohort])) {
+                    if (is_number($addcohort)) {
+                        // Only non-numeric idnumbers!
+                        $cohort = $DB->get_record('cohort', ['id' => $addcohort]);
+                    } else {
+                        $cohort = $DB->get_record('cohort', ['idnumber' => $addcohort]);
+                        if (empty($cohort) && has_capability('moodle/cohort:manage', \context_system::instance())) {
+                            // Cohort was not found. Create a new one.
+                            $cohortid = cohort_add_cohort((object)array(
+                                'idnumber' => $addcohort,
+                                'name' => $addcohort,
+                                'contextid' => \context_system::instance()->id
+                            ));
+                            $cohort = $DB->get_record('cohort', ['id' => $cohortid]);
+                        }
+                    }
+
+                    if (empty($cohort)) {
+                        $this->cohorts[$addcohort] = get_string('unknowncohort', 'core_cohort', s($addcohort));
+                    } else if (!empty($cohort->component)) {
+                        // Cohorts synchronised with external sources must not be modified!
+                        $this->cohorts[$addcohort] = get_string('external', 'core_cohort');
+                    } else {
+                        $this->cohorts[$addcohort] = $cohort;
+                    }
+                }
+
+                if (is_object($this->cohorts[$addcohort])) {
+                    $cohort = $this->cohorts[$addcohort];
+                    if (!$DB->record_exists('cohort_members', ['cohortid' => $cohort->id, 'userid' => $user->id])) {
+                        cohort_add_member($cohort->id, $user->id);
+                        // We might add special column later, for now let's abuse enrolments.
+                        $this->upt->track('enrolments', get_string('useradded', 'core_cohort', s($cohort->name)), 'info');
+                    }
+                } else {
+                    // Error message.
+                    $this->upt->track('enrolments', $this->cohorts[$addcohort], 'error');
+                }
+            }
+        }
+
+        // Find course enrolments, groups, roles/types and enrol periods
+        // this is again a special case, we always do this for any updated or created users.
+        foreach ($this->get_file_columns() as $column) {
+            if (preg_match('/^sysrole\d+$/', $column)) {
+
+                if (!empty($user->$column)) {
+                    $sysrolename = $user->$column;
+                    if ($sysrolename[0] == '-') {
+                        $removing = true;
+                        $sysrolename = substr($sysrolename, 1);
+                    } else {
+                        $removing = false;
+                    }
+
+                    if (array_key_exists($sysrolename, $this->sysrolecache)) {
+                        $sysroleid = $this->sysrolecache[$sysrolename]->id;
+                    } else {
+                        $this->upt->track('enrolments', get_string('unknownrole', 'error', s($sysrolename)), 'error');
+                        continue;
+                    }
+
+                    if ($removing) {
+                        if (user_has_role_assignment($user->id, $sysroleid, SYSCONTEXTID)) {
+                            role_unassign($sysroleid, $user->id, SYSCONTEXTID);
+                            $this->upt->track('enrolments', get_string('unassignedsysrole',
+                                'tool_uploaduser', $this->sysrolecache[$sysroleid]->name), 'info');
+                        }
+                    } else {
+                        if (!user_has_role_assignment($user->id, $sysroleid, SYSCONTEXTID)) {
+                            role_assign($sysroleid, $user->id, SYSCONTEXTID);
+                            $this->upt->track('enrolments', get_string('assignedsysrole',
+                                'tool_uploaduser', $this->sysrolecache[$sysroleid]->name), 'info');
+                        }
+                    }
+                }
+
+                continue;
+            }
+            if (!preg_match('/^course\d+$/', $column)) {
+                continue;
+            }
+            $i = substr($column, 6);
+
+            if (empty($user->{'course'.$i})) {
+                continue;
+            }
+            $shortname = $user->{'course'.$i};
+            if (!array_key_exists($shortname, $this->ccache)) {
+                if (!$course = $DB->get_record('course', ['shortname' => $shortname], 'id, shortname')) {
+                    $this->upt->track('enrolments', get_string('unknowncourse', 'error', s($shortname)), 'error');
+                    continue;
+                }
+                $ccache[$shortname] = $course;
+                $ccache[$shortname]->groups = null;
+            }
+            $courseid      = $ccache[$shortname]->id;
+            $coursecontext = \context_course::instance($courseid);
+            if (!isset($this->manualcache[$courseid])) {
+                $this->manualcache[$courseid] = false;
+                if ($this->manualenrol) {
+                    if ($instances = enrol_get_instances($courseid, false)) {
+                        foreach ($instances as $instance) {
+                            if ($instance->enrol === 'manual') {
+                                $this->manualcache[$courseid] = $instance;
+                                break;
+                            }
+                        }
+                    }
+                }
+            }
+
+            if ($courseid == SITEID) {
+                // Technically frontpage does not have enrolments, but only role assignments,
+                // let's not invent new lang strings here for this rarely used feature.
+
+                if (!empty($user->{'role'.$i})) {
+                    $rolename = $user->{'role'.$i};
+                    if (array_key_exists($rolename, $this->rolecache)) {
+                        $roleid = $this->rolecache[$rolename]->id;
+                    } else {
+                        $this->upt->track('enrolments', get_string('unknownrole', 'error', s($rolename)), 'error');
+                        continue;
+                    }
+
+                    role_assign($roleid, $user->id, \context_course::instance($courseid));
+
+                    $a = new \stdClass();
+                    $a->course = $shortname;
+                    $a->role   = $this->rolecache[$roleid]->name;
+                    $this->upt->track('enrolments', get_string('enrolledincourserole', 'enrol_manual', $a), 'info');
+                }
+
+            } else if ($this->manualenrol and $this->manualcache[$courseid]) {
+
+                // Find role.
+                $roleid = false;
+                if (!empty($user->{'role'.$i})) {
+                    $rolename = $user->{'role'.$i};
+                    if (array_key_exists($rolename, $this->rolecache)) {
+                        $roleid = $this->rolecache[$rolename]->id;
+                    } else {
+                        $this->upt->track('enrolments', get_string('unknownrole', 'error', s($rolename)), 'error');
+                        continue;
+                    }
+
+                } else if (!empty($user->{'type'.$i})) {
+                    // If no role, then find "old" enrolment type.
+                    $addtype = $user->{'type'.$i};
+                    if ($addtype < 1 or $addtype > 3) {
+                        $this->upt->track('enrolments', get_string('error').': typeN = 1|2|3', 'error');
+                        continue;
+                    } else if (empty($this->formdata->{'uulegacy'.$addtype})) {
+                        continue;
+                    } else {
+                        $roleid = $this->formdata->{'uulegacy'.$addtype};
+                    }
+                } else {
+                    // No role specified, use the default from manual enrol plugin.
+                    $roleid = $this->manualcache[$courseid]->roleid;
+                }
+
+                if ($roleid) {
+                    // Find duration and/or enrol status.
+                    $timeend = 0;
+                    $timestart = $this->today;
+                    $status = null;
+
+                    if (isset($user->{'enrolstatus'.$i})) {
+                        $enrolstatus = $user->{'enrolstatus'.$i};
+                        if ($enrolstatus == '') {
+                            $status = null;
+                        } else if ($enrolstatus === (string)ENROL_USER_ACTIVE) {
+                            $status = ENROL_USER_ACTIVE;
+                        } else if ($enrolstatus === (string)ENROL_USER_SUSPENDED) {
+                            $status = ENROL_USER_SUSPENDED;
+                        } else {
+                            debugging('Unknown enrolment status.');
+                        }
+                    }
+
+                    if (!empty($user->{'enroltimestart'.$i})) {
+                        $parsedtimestart = strtotime($user->{'enroltimestart'.$i});
+                        if ($parsedtimestart !== false) {
+                            $timestart = $parsedtimestart;
+                        }
+                    }
+
+                    if (!empty($user->{'enrolperiod'.$i})) {
+                        $duration = (int)$user->{'enrolperiod'.$i} * 60 * 60 * 24; // Convert days to seconds.
+                        if ($duration > 0) { // Sanity check.
+                            $timeend = $timestart + $duration;
+                        }
+                    } else if ($this->manualcache[$courseid]->enrolperiod > 0) {
+                        $timeend = $timestart + $this->manualcache[$courseid]->enrolperiod;
+                    }
+
+                    $this->manualenrol->enrol_user($this->manualcache[$courseid], $user->id, $roleid,
+                        $timestart, $timeend, $status);
+
+                    $a = new \stdClass();
+                    $a->course = $shortname;
+                    $a->role   = $this->rolecache[$roleid]->name;
+                    $this->upt->track('enrolments', get_string('enrolledincourserole', 'enrol_manual', $a), 'info');
+                }
+            }
+
+            // Find group to add to.
+            if (!empty($user->{'group'.$i})) {
+                // Make sure user is enrolled into course before adding into groups.
+                if (!is_enrolled($coursecontext, $user->id)) {
+                    $this->upt->track('enrolments', get_string('addedtogroupnotenrolled', '', $user->{'group'.$i}), 'error');
+                    continue;
+                }
+                // Build group cache.
+                if (is_null($ccache[$shortname]->groups)) {
+                    $ccache[$shortname]->groups = array();
+                    if ($groups = groups_get_all_groups($courseid)) {
+                        foreach ($groups as $gid => $group) {
+                            $ccache[$shortname]->groups[$gid] = new \stdClass();
+                            $ccache[$shortname]->groups[$gid]->id   = $gid;
+                            $ccache[$shortname]->groups[$gid]->name = $group->name;
+                            if (!is_numeric($group->name)) { // Only non-numeric names are supported!!!
+                                $ccache[$shortname]->groups[$group->name] = new \stdClass();
+                                $ccache[$shortname]->groups[$group->name]->id   = $gid;
+                                $ccache[$shortname]->groups[$group->name]->name = $group->name;
+                            }
+                        }
+                    }
+                }
+                // Group exists?
+                $addgroup = $user->{'group'.$i};
+                if (!array_key_exists($addgroup, $ccache[$shortname]->groups)) {
+                    // If group doesn't exist,  create it.
+                    $newgroupdata = new \stdClass();
+                    $newgroupdata->name = $addgroup;
+                    $newgroupdata->courseid = $ccache[$shortname]->id;
+                    $newgroupdata->description = '';
+                    $gid = groups_create_group($newgroupdata);
+                    if ($gid) {
+                        $ccache[$shortname]->groups[$addgroup] = new \stdClass();
+                        $ccache[$shortname]->groups[$addgroup]->id   = $gid;
+                        $ccache[$shortname]->groups[$addgroup]->name = $newgroupdata->name;
+                    } else {
+                        $this->upt->track('enrolments', get_string('unknowngroup', 'error', s($addgroup)), 'error');
+                        continue;
+                    }
+                }
+                $gid   = $ccache[$shortname]->groups[$addgroup]->id;
+                $gname = $ccache[$shortname]->groups[$addgroup]->name;
+
+                try {
+                    if (groups_add_member($gid, $user->id)) {
+                        $this->upt->track('enrolments', get_string('addedtogroup', '', s($gname)), 'info');
+                    } else {
+                        $this->upt->track('enrolments', get_string('addedtogroupnot', '', s($gname)), 'error');
+                    }
+                } catch (\moodle_exception $e) {
+                    $this->upt->track('enrolments', get_string('addedtogroupnot', '', s($gname)), 'error');
+                    continue;
+                }
+            }
+        }
+        if (($invalid = \core_user::validate($user)) !== true) {
+            $this->upt->track('status', get_string('invaliduserdata', 'tool_uploaduser', s($user->username)), 'warning');
+        }
+    }
+
+    /**
+     * Summary about the whole process (how many users created, skipped, updated, etc)
+     *
+     * @return array
+     */
+    public function get_stats() {
+        $lines = [];
+
+        if ($this->get_operation_type() != UU_USER_UPDATE) {
+            $lines[] = get_string('userscreated', 'tool_uploaduser').': '.$this->usersnew;
+        }
+        if ($this->get_operation_type() == UU_USER_UPDATE or $this->get_operation_type() == UU_USER_ADD_UPDATE) {
+            $lines[] = get_string('usersupdated', 'tool_uploaduser').': '.$this->usersupdated;
+        }
+        if ($this->get_allow_deletes()) {
+            $lines[] = get_string('usersdeleted', 'tool_uploaduser').': '.$this->deletes;
+            $lines[] = get_string('deleteerrors', 'tool_uploaduser').': '.$this->deleteerrors;
+        }
+        if ($this->get_allow_renames()) {
+            $lines[] = get_string('usersrenamed', 'tool_uploaduser').': '.$this->renames;
+            $lines[] = get_string('renameerrors', 'tool_uploaduser').': '.$this->renameerrors;
+        }
+        if ($usersskipped = $this->usersskipped) {
+            $lines[] = get_string('usersskipped', 'tool_uploaduser').': '.$usersskipped;
+        }
+        $lines[] = get_string('usersweakpassword', 'tool_uploaduser').': '.$this->weakpasswords;
+        $lines[] = get_string('errors', 'tool_uploaduser').': '.$this->userserrors;
+
+        return $lines;
+    }
+}
diff --git a/admin/tool/uploaduser/cli/uploaduser.php b/admin/tool/uploaduser/cli/uploaduser.php
new file mode 100644 (file)
index 0000000..5db9e02
--- /dev/null
@@ -0,0 +1,53 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * CLI script to upload users
+ *
+ * @package     tool_uploaduser
+ * @copyright   2020 Marina Glancy
+ * @license     http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+define('CLI_SCRIPT', true);
+
+require_once(__DIR__ . '/../../../../config.php');
+require_once($CFG->libdir . '/clilib.php');
+
+if (moodle_needs_upgrading()) {
+    cli_error("Moodle upgrade pending, export execution suspended.");
+}
+
+// Increase time and memory limit.
+core_php_time_limit::raise();
+raise_memory_limit(MEMORY_EXTRA);
+
+// Emulate normal session - we use admin account by default, set language to the site language.
+cron_setup_user();
+$USER->lang = $CFG->lang;
+
+$clihelper = new \tool_uploaduser\cli_helper();
+
+if ($clihelper->get_cli_option('help')) {
+    $clihelper->print_help();
+    die();
+}
+
+$clihelper->process();
+
+foreach ($clihelper->get_stats() as $line) {
+    cli_writeln($line);
+}
index eb881f6..a0ef7ab 100644 (file)
 require('../../../config.php');
 require_once($CFG->libdir.'/adminlib.php');
 require_once($CFG->libdir.'/csvlib.class.php');
-require_once($CFG->dirroot.'/user/profile/lib.php');
-require_once($CFG->dirroot.'/user/lib.php');
-require_once($CFG->dirroot.'/group/lib.php');
-require_once($CFG->dirroot.'/cohort/lib.php');
-require_once('locallib.php');
-require_once('user_form.php');
-require_once('classes/local/field_value_validators.php');
-use tool_uploaduser\local\field_value_validators;
+require_once($CFG->dirroot.'/'.$CFG->admin.'/tool/uploaduser/locallib.php');
+require_once($CFG->dirroot.'/'.$CFG->admin.'/tool/uploaduser/user_form.php');
 
 $iid         = optional_param('iid', '', PARAM_INT);
 $previewrows = optional_param('previewrows', 10, PARAM_INT);
 
-core_php_time_limit::raise(60*60); // 1 hour should be enough
+core_php_time_limit::raise(60 * 60); // 1 hour should be enough.
 raise_memory_limit(MEMORY_HUGE);
 
 admin_externalpage_setup('tooluploaduser');
-require_capability('moodle/site:uploadusers', context_system::instance());
-
-$struserrenamed             = get_string('userrenamed', 'tool_uploaduser');
-$strusernotrenamedexists    = get_string('usernotrenamedexists', 'error');
-$strusernotrenamedmissing   = get_string('usernotrenamedmissing', 'error');
-$strusernotrenamedoff       = get_string('usernotrenamedoff', 'error');
-$strusernotrenamedadmin     = get_string('usernotrenamedadmin', 'error');
-
-$struserupdated             = get_string('useraccountupdated', 'tool_uploaduser');
-$strusernotupdated          = get_string('usernotupdatederror', 'error');
-$strusernotupdatednotexists = get_string('usernotupdatednotexists', 'error');
-$strusernotupdatedadmin     = get_string('usernotupdatedadmin', 'error');
-
-$struseruptodate            = get_string('useraccountuptodate', 'tool_uploaduser');
-
-$struseradded               = get_string('newuser');
-$strusernotadded            = get_string('usernotaddedregistered', 'error');
-$strusernotaddederror       = get_string('usernotaddederror', 'error');
-
-$struserdeleted             = get_string('userdeleted', 'tool_uploaduser');
-$strusernotdeletederror     = get_string('usernotdeletederror', 'error');
-$strusernotdeletedmissing   = get_string('usernotdeletedmissing', 'error');
-$strusernotdeletedoff       = get_string('usernotdeletedoff', 'error');
-$strusernotdeletedadmin     = get_string('usernotdeletedadmin', 'error');
-
-$strcannotassignrole        = get_string('cannotassignrole', 'error');
-
-$struserauthunsupported     = get_string('userauthunsupported', 'error');
-$stremailduplicate          = get_string('useremailduplicate', 'error');
-
-$strinvalidpasswordpolicy   = get_string('invalidpasswordpolicy', 'error');
-$errorstr                   = get_string('error');
-
-$stryes                     = get_string('yes');
-$strno                      = get_string('no');
-$stryesnooptions = array(0=>$strno, 1=>$stryes);
 
 $returnurl = new moodle_url('/admin/tool/uploaduser/index.php');
 $bulknurl  = new moodle_url('/admin/user/user_bulk.php');
 
-$today = time();
-$today = make_timestamp(date('Y', $today), date('m', $today), date('d', $today), 0, 0, 0);
-
-// array of all valid fields for validation
-$STD_FIELDS = array('id', 'username', 'email', 'emailstop',
-        'city', 'country', 'lang', 'timezone', 'mailformat',
-        'maildisplay', 'maildigest', 'htmleditor', 'autosubscribe',
-        'institution', 'department', 'idnumber', 'skype',
-        'msn', 'aim', 'yahoo', 'icq', 'phone1', 'phone2', 'address',
-        'url', 'description', 'descriptionformat', 'password',
-        'auth',        // watch out when changing auth type or using external auth plugins!
-        'oldusername', // use when renaming users - this is the original username
-        'suspended',   // 1 means suspend user account, 0 means activate user account, nothing means keep as is for existing users
-        'theme',       // Define a theme for user when 'allowuserthemes' is enabled.
-        'deleted',     // 1 means delete user
-        'mnethostid',  // Can not be used for adding, updating or deleting of users - only for enrolments, groups, cohorts and suspending.
-        'interests',
-    );
-// Include all name fields.
-$STD_FIELDS = array_merge($STD_FIELDS, get_all_user_name_fields());
-
-$PRF_FIELDS = array();
-if ($proffields = $DB->get_records('user_info_field')) {
-    foreach ($proffields as $key => $proffield) {
-        $profilefieldname = 'profile_field_'.$proffield->shortname;
-        $PRF_FIELDS[] = $profilefieldname;
-        // Re-index $proffields with key as shortname. This will be
-        // used while checking if profile data is key and needs to be converted (eg. menu profile field)
-        $proffields[$profilefieldname] = $proffield;
-        unset($proffields[$key]);
-    }
-}
-
 if (empty($iid)) {
     $mform1 = new admin_uploaduser_form1();
 
@@ -131,9 +56,7 @@ if (empty($iid)) {
         if (!is_null($csvloaderror)) {
             print_error('csvloaderror', '', $returnurl, $csvloaderror);
         }
-        // test if columns ok
-        $filecolumns = uu_validate_user_upload_columns($cir, $STD_FIELDS, $PRF_FIELDS, $returnurl);
-        // continue to form2
+        // Continue to form2.
 
     } else {
         echo $OUTPUT->header();
@@ -146,1033 +69,33 @@ if (empty($iid)) {
     }
 } else {
     $cir = new csv_import_reader($iid, 'uploaduser');
-    $filecolumns = uu_validate_user_upload_columns($cir, $STD_FIELDS, $PRF_FIELDS, $returnurl);
 }
 
-$mform2 = new admin_uploaduser_form2(null, array('columns'=>$filecolumns, 'data'=>array('iid'=>$iid, 'previewrows'=>$previewrows)));
+// Test if columns ok.
+$process = new \tool_uploaduser\process($cir);
+$filecolumns = $process->get_file_columns();
+
+$mform2 = new admin_uploaduser_form2(null,
+    ['columns' => $filecolumns, 'data' => ['iid' => $iid, 'previewrows' => $previewrows]]);
 
-// If a file has been uploaded, then process it
+// If a file has been uploaded, then process it.
 if ($formdata = $mform2->is_cancelled()) {
     $cir->cleanup(true);
     redirect($returnurl);
 
 } else if ($formdata = $mform2->get_data()) {
-    // Print the header
+    // Print the header.
     echo $OUTPUT->header();
     echo $OUTPUT->heading(get_string('uploadusersresult', 'tool_uploaduser'));
 
-    $optype = $formdata->uutype;
-
-    $updatetype        = isset($formdata->uuupdatetype) ? $formdata->uuupdatetype : 0;
-    $createpasswords   = (!empty($formdata->uupasswordnew) and $optype != UU_USER_UPDATE);
-    $updatepasswords   = (!empty($formdata->uupasswordold)  and $optype != UU_USER_ADDNEW and $optype != UU_USER_ADDINC and ($updatetype == UU_UPDATE_FILEOVERRIDE or $updatetype == UU_UPDATE_ALLOVERRIDE));
-    $allowrenames      = (!empty($formdata->uuallowrenames) and $optype != UU_USER_ADDNEW and $optype != UU_USER_ADDINC);
-    $allowdeletes      = (!empty($formdata->uuallowdeletes) and $optype != UU_USER_ADDNEW and $optype != UU_USER_ADDINC);
-    $allowsuspends     = (!empty($formdata->uuallowsuspends));
-    $bulk              = $formdata->uubulk;
-    $noemailduplicates = empty($CFG->allowaccountssameemail) ? 1 : $formdata->uunoemailduplicates;
-    $standardusernames = $formdata->uustandardusernames;
-    $resetpasswords    = isset($formdata->uuforcepasswordchange) ? $formdata->uuforcepasswordchange : UU_PWRESET_NONE;
-
-    // verification moved to two places: after upload and into form2
-    $usersnew      = 0;
-    $usersupdated  = 0;
-    $usersuptodate = 0; //not printed yet anywhere
-    $userserrors   = 0;
-    $deletes       = 0;
-    $deleteerrors  = 0;
-    $renames       = 0;
-    $renameerrors  = 0;
-    $usersskipped  = 0;
-    $weakpasswords = 0;
-
-    // caches
-    $ccache         = array(); // course cache - do not fetch all courses here, we  will not probably use them all anyway!
-    $cohorts        = array();
-    $rolecache      = uu_allowed_roles_cache(); // Course roles lookup cache.
-    $sysrolecache   = uu_allowed_sysroles_cache(); // System roles lookup cache.
-    $manualcache    = array(); // cache of used manual enrol plugins in each course
-    $supportedauths = uu_supported_auths(); // officially supported plugins that are enabled
-
-    // we use only manual enrol plugin here, if it is disabled no enrol is done
-    if (enrol_is_enabled('manual')) {
-        $manual = enrol_get_plugin('manual');
-    } else {
-        $manual = NULL;
-    }
-
-    // clear bulk selection
-    if ($bulk) {
-        $SESSION->bulk_users = array();
-    }
-
-    // init csv import helper
-    $cir->init();
-    $linenum = 1; //column header is first line
-
-    // init upload progress tracker
-    $upt = new uu_progress_tracker();
-    $upt->start(); // start table
-    $validation = array();
-    while ($line = $cir->next()) {
-        $upt->flush();
-        $linenum++;
-
-        $upt->track('line', $linenum);
-
-        $user = new stdClass();
-
-        // add fields to user object
-        foreach ($line as $keynum => $value) {
-            if (!isset($filecolumns[$keynum])) {
-                // this should not happen
-                continue;
-            }
-            $key = $filecolumns[$keynum];
-            if (strpos($key, 'profile_field_') === 0) {
-                //NOTE: bloody mega hack alert!!
-                if (isset($USER->$key) and is_array($USER->$key)) {
-                    // this must be some hacky field that is abusing arrays to store content and format
-                    $user->$key = array();
-                    $user->{$key['text']}   = $value;
-                    $user->{$key['format']} = FORMAT_MOODLE;
-                } else {
-                    $user->$key = trim($value);
-                }
-            } else {
-                $user->$key = trim($value);
-            }
-
-            if (in_array($key, $upt->columns)) {
-                // default value in progress tracking table, can be changed later
-                $upt->track($key, s($value), 'normal');
-            }
-        }
-        if (!isset($user->username)) {
-            // prevent warnings below
-            $user->username = '';
-        }
-
-        if ($optype == UU_USER_ADDNEW or $optype == UU_USER_ADDINC) {
-            // user creation is a special case - the username may be constructed from templates using firstname and lastname
-            // better never try this in mixed update types
-            $error = false;
-            if (!isset($user->firstname) or $user->firstname === '') {
-                $upt->track('status', get_string('missingfield', 'error', 'firstname'), 'error');
-                $upt->track('firstname', $errorstr, 'error');
-                $error = true;
-            }
-            if (!isset($user->lastname) or $user->lastname === '') {
-                $upt->track('status', get_string('missingfield', 'error', 'lastname'), 'error');
-                $upt->track('lastname', $errorstr, 'error');
-                $error = true;
-            }
-            if ($error) {
-                $userserrors++;
-                continue;
-            }
-            // we require username too - we might use template for it though
-            if (empty($user->username) and !empty($formdata->username)) {
-                $user->username = uu_process_template($formdata->username, $user);
-                $upt->track('username', s($user->username));
-            }
-        }
-
-        // normalize username
-        $originalusername = $user->username;
-        if ($standardusernames) {
-            $user->username = core_user::clean_field($user->username, 'username');
-        }
-
-        // make sure we really have username
-        if (empty($user->username)) {
-            $upt->track('status', get_string('missingfield', 'error', 'username'), 'error');
-            $upt->track('username', $errorstr, 'error');
-            $userserrors++;
-            continue;
-        } else if ($user->username === 'guest') {
-            $upt->track('status', get_string('guestnoeditprofileother', 'error'), 'error');
-            $userserrors++;
-            continue;
-        }
-
-        if ($user->username !== core_user::clean_field($user->username, 'username')) {
-            $upt->track('status', get_string('invalidusername', 'error', 'username'), 'error');
-            $upt->track('username', $errorstr, 'error');
-            $userserrors++;
-        }
-
-        if (empty($user->mnethostid)) {
-            $user->mnethostid = $CFG->mnet_localhost_id;
-        }
-
-        if ($existinguser = $DB->get_record('user', array('username'=>$user->username, 'mnethostid'=>$user->mnethostid))) {
-            $upt->track('id', $existinguser->id, 'normal', false);
-        }
-
-        if ($user->mnethostid == $CFG->mnet_localhost_id) {
-            $remoteuser = false;
-
-            // Find out if username incrementing required.
-            if ($existinguser and $optype == UU_USER_ADDINC) {
-                $user->username = uu_increment_username($user->username);
-                $existinguser = false;
-            }
-
-        } else {
-            if (!$existinguser or $optype == UU_USER_ADDINC) {
-                $upt->track('status', get_string('errormnetadd', 'tool_uploaduser'), 'error');
-                $userserrors++;
-                continue;
-            }
-
-            $remoteuser = true;
-
-            // Make sure there are no changes of existing fields except the suspended status.
-            foreach ((array)$existinguser as $k => $v) {
-                if ($k === 'suspended') {
-                    continue;
-                }
-                if (property_exists($user, $k)) {
-                    $user->$k = $v;
-                }
-                if (in_array($k, $upt->columns)) {
-                    if ($k === 'password' or $k === 'oldusername' or $k === 'deleted') {
-                        $upt->track($k, '', 'normal', false);
-                    } else {
-                        $upt->track($k, s($v), 'normal', false);
-                    }
-                }
-            }
-            unset($user->oldusername);
-            unset($user->password);
-            $user->auth = $existinguser->auth;
-        }
-
-        // notify about nay username changes
-        if ($originalusername !== $user->username) {
-            $upt->track('username', '', 'normal', false); // clear previous
-            $upt->track('username', s($originalusername).'-->'.s($user->username), 'info');
-        } else {
-            $upt->track('username', s($user->username), 'normal', false);
-        }
-
-        // Verify if the theme is valid and allowed to be set.
-        if (isset($user->theme)) {
-            list($status, $message) = field_value_validators::validate_theme($user->theme);
-            if ($status !== 'normal' && !empty($message)) {
-                $upt->track('status', $message, $status);
-                // Unset the theme when validation fails.
-                unset($user->theme);
-            }
-        }
-
-        // add default values for remaining fields
-        $formdefaults = array();
-        if (!$existinguser || ($updatetype != UU_UPDATE_FILEOVERRIDE && $updatetype != UU_UPDATE_NOCHANGES)) {
-            foreach ($STD_FIELDS as $field) {
-                if (isset($user->$field)) {
-                    continue;
-                }
-                // all validation moved to form2
-                if (isset($formdata->$field)) {
-                    // process templates
-                    $user->$field = uu_process_template($formdata->$field, $user);
-                    $formdefaults[$field] = true;
-                    if (in_array($field, $upt->columns)) {
-                        $upt->track($field, s($user->$field), 'normal');
-                    }
-                }
-            }
-            foreach ($PRF_FIELDS as $field) {
-                if (isset($user->$field)) {
-                    continue;
-                }
-                if (isset($formdata->$field)) {
-                    // process templates
-                    $user->$field = uu_process_template($formdata->$field, $user);
-
-                    // Form contains key and later code expects value.
-                    // Convert key to value for required profile fields.
-                    require_once($CFG->dirroot.'/user/profile/field/'.$proffields[$field]->datatype.'/field.class.php');
-                    $profilefieldclass = 'profile_field_'.$proffields[$field]->datatype;
-                    $profilefield = new $profilefieldclass($proffields[$field]->id);
-                    if (method_exists($profilefield, 'convert_external_data')) {
-                        $user->$field = $profilefield->edit_save_data_preprocess($user->$field, null);
-                    }
-
-                    $formdefaults[$field] = true;
-                }
-            }
-        }
-
-        // delete user
-        if (!empty($user->deleted)) {
-            if (!$allowdeletes or $remoteuser) {
-                $usersskipped++;
-                $upt->track('status', $strusernotdeletedoff, 'warning');
-                continue;
-            }
-            if ($existinguser) {
-                if (is_siteadmin($existinguser->id)) {
-                    $upt->track('status', $strusernotdeletedadmin, 'error');
-                    $deleteerrors++;
-                    continue;
-                }
-                if (delete_user($existinguser)) {
-                    $upt->track('status', $struserdeleted);
-                    $deletes++;
-                } else {
-                    $upt->track('status', $strusernotdeletederror, 'error');
-                    $deleteerrors++;
-                }
-            } else {
-                $upt->track('status', $strusernotdeletedmissing, 'error');
-                $deleteerrors++;
-            }
-            continue;
-        }
-        // we do not need the deleted flag anymore
-        unset($user->deleted);
-
-        // renaming requested?
-        if (!empty($user->oldusername) ) {
-            if (!$allowrenames) {
-                $usersskipped++;
-                $upt->track('status', $strusernotrenamedoff, 'warning');
-                continue;
-            }
-
-            if ($existinguser) {
-                $upt->track('status', $strusernotrenamedexists, 'error');
-                $renameerrors++;
-                continue;
-            }
-
-            if ($user->username === 'guest') {
-                $upt->track('status', get_string('guestnoeditprofileother', 'error'), 'error');
-                $renameerrors++;
-                continue;
-            }
-
-            if ($standardusernames) {
-                $oldusername = core_user::clean_field($user->oldusername, 'username');
-            } else {
-                $oldusername = $user->oldusername;
-            }
-
-            // no guessing when looking for old username, it must be exact match
-            if ($olduser = $DB->get_record('user', array('username'=>$oldusername, 'mnethostid'=>$CFG->mnet_localhost_id))) {
-                $upt->track('id', $olduser->id, 'normal', false);
-                if (is_siteadmin($olduser->id)) {
-                    $upt->track('status', $strusernotrenamedadmin, 'error');
-                    $renameerrors++;
-                    continue;
-                }
-                $DB->set_field('user', 'username', $user->username, array('id'=>$olduser->id));
-                $upt->track('username', '', 'normal', false); // clear previous
-                $upt->track('username', s($oldusername).'-->'.s($user->username), 'info');
-                $upt->track('status', $struserrenamed);
-                $renames++;
-            } else {
-                $upt->track('status', $strusernotrenamedmissing, 'error');
-                $renameerrors++;
-                continue;
-            }
-            $existinguser = $olduser;
-            $existinguser->username = $user->username;
-        }
-
-        // can we process with update or insert?
-        $skip = false;
-        switch ($optype) {
-            case UU_USER_ADDNEW:
-                if ($existinguser) {
-                    $usersskipped++;
-                    $upt->track('status', $strusernotadded, 'warning');
-                    $skip = true;
-                }
-                break;
-
-            case UU_USER_ADDINC:
-                if ($existinguser) {
-                    //this should not happen!
-                    $upt->track('status', $strusernotaddederror, 'error');
-                    $userserrors++;
-                    $skip = true;
-                }
-                break;
-
-            case UU_USER_ADD_UPDATE:
-                break;
-
-            case UU_USER_UPDATE:
-                if (!$existinguser) {
-                    $usersskipped++;
-                    $upt->track('status', $strusernotupdatednotexists, 'warning');
-                    $skip = true;
-                }
-                break;
-
-            default:
-                // unknown type
-                $skip = true;
-        }
-
-        if ($skip) {
-            continue;
-        }
-
-        if ($existinguser) {
-            $user->id = $existinguser->id;
-
-            $upt->track('username', html_writer::link(new moodle_url('/user/profile.php', array('id'=>$existinguser->id)), s($existinguser->username)), 'normal', false);
-            $upt->track('suspended', $stryesnooptions[$existinguser->suspended] , 'normal', false);
-            $upt->track('auth', $existinguser->auth, 'normal', false);
-
-            if (is_siteadmin($user->id)) {
-                $upt->track('status', $strusernotupdatedadmin, 'error');
-                $userserrors++;
-                continue;
-            }
-
-            $existinguser->timemodified = time();
-            // do NOT mess with timecreated or firstaccess here!
-
-            //load existing profile data
-            profile_load_data($existinguser);
-
-            $doupdate = false;
-            $dologout = false;
-
-            if ($updatetype != UU_UPDATE_NOCHANGES and !$remoteuser) {
-                if (!empty($user->auth) and $user->auth !== $existinguser->auth) {
-                    $upt->track('auth', s($existinguser->auth).'-->'.s($user->auth), 'info', false);
-                    $existinguser->auth = $user->auth;
-                    if (!isset($supportedauths[$user->auth])) {
-                        $upt->track('auth', $struserauthunsupported, 'warning');
-                    }
-                    $doupdate = true;
-                    if ($existinguser->auth === 'nologin') {
-                        $dologout = true;
-                    }
-                }
-                $allcolumns = array_merge($STD_FIELDS, $PRF_FIELDS);
-                foreach ($allcolumns as $column) {
-                    if ($column === 'username' or $column === 'password' or $column === 'auth' or $column === 'suspended') {
-                        // these can not be changed here
-                        continue;
-                    }
-                    if (!property_exists($user, $column) or !property_exists($existinguser, $column)) {
-                        continue;
-                    }
-                    if ($updatetype == UU_UPDATE_MISSING) {
-                        if (!is_null($existinguser->$column) and $existinguser->$column !== '') {
-                            continue;
-                        }
-                    } else if ($updatetype == UU_UPDATE_ALLOVERRIDE) {
-                        // we override everything
-
-                    } else if ($updatetype == UU_UPDATE_FILEOVERRIDE) {
-                        if (!empty($formdefaults[$column])) {
-                            // do not override with form defaults
-                            continue;
-                        }
-                    }
-                    if ($existinguser->$column !== $user->$column) {
-                        if ($column === 'email') {
-                            $select = $DB->sql_like('email', ':email', false, true, false, '|');
-                            $params = array('email' => $DB->sql_like_escape($user->email, '|'));
-                            if ($DB->record_exists_select('user', $select , $params)) {
-
-                                $changeincase = core_text::strtolower($existinguser->$column) === core_text::strtolower(
-                                                $user->$column);
-
-                                if ($changeincase) {
-                                    // If only case is different then switch to lower case and carry on.
-                                    $user->$column = core_text::strtolower($user->$column);
-                                    continue;
-                                } else if ($noemailduplicates) {
-                                    $upt->track('email', $stremailduplicate, 'error');
-                                    $upt->track('status', $strusernotupdated, 'error');
-                                    $userserrors++;
-                                    continue 2;
-                                } else {
-                                    $upt->track('email', $stremailduplicate, 'warning');
-                                }
-                            }
-                            if (!validate_email($user->email)) {
-                                $upt->track('email', get_string('invalidemail'), 'warning');
-                            }
-                        }
-
-                        if ($column === 'lang') {
-                            if (empty($user->lang)) {
-                                // Do not change to not-set value.
-                                continue;
-                            } else if (core_user::clean_field($user->lang, 'lang') === '') {
-                                $upt->track('status', get_string('cannotfindlang', 'error', $user->lang), 'warning');
-                                continue;
-                            }
-                        }
-
-                        if (in_array($column, $upt->columns)) {
-                            $upt->track($column, s($existinguser->$column).'-->'.s($user->$column), 'info', false);
-                        }
-                        $existinguser->$column = $user->$column;
-                        $doupdate = true;
-                    }
-                }
-            }
-
-            try {
-                $auth = get_auth_plugin($existinguser->auth);
-            } catch (Exception $e) {
-                $upt->track('auth', get_string('userautherror', 'error', s($existinguser->auth)), 'error');
-                $upt->track('status', $strusernotupdated, 'error');
-                $userserrors++;
-                continue;
-            }
-            $isinternalauth = $auth->is_internal();
-
-            // deal with suspending and activating of accounts
-            if ($allowsuspends and isset($user->suspended) and $user->suspended !== '') {
-                $user->suspended = $user->suspended ? 1 : 0;
-                if ($existinguser->suspended != $user->suspended) {
-                    $upt->track('suspended', '', 'normal', false);
-                    $upt->track('suspended', $stryesnooptions[$existinguser->suspended].'-->'.$stryesnooptions[$user->suspended], 'info', false);
-                    $existinguser->suspended = $user->suspended;
-                    $doupdate = true;
-                    if ($existinguser->suspended) {
-                        $dologout = true;
-                    }
-                }
-            }
-
-            // changing of passwords is a special case
-            // do not force password changes for external auth plugins!
-            $oldpw = $existinguser->password;
-
-            if ($remoteuser) {
-                // Do not mess with passwords of remote users.
-
-            } else if (!$isinternalauth) {
-                $existinguser->password = AUTH_PASSWORD_NOT_CACHED;
-                $upt->track('password', '-', 'normal', false);
-                // clean up prefs
-                unset_user_preference('create_password', $existinguser);
-                unset_user_preference('auth_forcepasswordchange', $existinguser);
-
-            } else if (!empty($user->password)) {
-                if ($updatepasswords) {
-                    // Check for passwords that we want to force users to reset next
-                    // time they log in.
-                    $errmsg = null;
-                    $weak = !check_password_policy($user->password, $errmsg, $user);
-                    if ($resetpasswords == UU_PWRESET_ALL or ($resetpasswords == UU_PWRESET_WEAK and $weak)) {
-                        if ($weak) {
-                            $weakpasswords++;
-                            $upt->track('password', $strinvalidpasswordpolicy, 'warning');
-                        }
-                        set_user_preference('auth_forcepasswordchange', 1, $existinguser);
-                    } else {
-                        unset_user_preference('auth_forcepasswordchange', $existinguser);
-                    }
-                    unset_user_preference('create_password', $existinguser); // no need to create password any more
-
-                    // Use a low cost factor when generating bcrypt hash otherwise
-                    // hashing would be slow when uploading lots of users. Hashes
-                    // will be automatically updated to a higher cost factor the first
-                    // time the user logs in.
-                    $existinguser->password = hash_internal_user_password($user->password, true);
-                    $upt->track('password', $user->password, 'normal', false);
-                } else {
-                    // do not print password when not changed
-                    $upt->track('password', '', 'normal', false);
-                }
-            }
-
-            if ($doupdate or $existinguser->password !== $oldpw) {
-                // We want only users that were really updated.
-                user_update_user($existinguser, false, false);
-
-                $upt->track('status', $struserupdated);
-                $usersupdated++;
-
-                if (!$remoteuser) {
-                    // pre-process custom profile menu fields data from csv file
-                    $existinguser = uu_pre_process_custom_profile_data($existinguser);
-                    // save custom profile fields data from csv file
-                    profile_save_data($existinguser);
-                }
-
-                if ($bulk == UU_BULK_UPDATED or $bulk == UU_BULK_ALL) {
-                    if (!in_array($user->id, $SESSION->bulk_users)) {
-                        $SESSION->bulk_users[] = $user->id;
-                    }
-                }
-
-                // Trigger event.
-                \core\event\user_updated::create_from_userid($existinguser->id)->trigger();
-
-            } else {
-                // no user information changed
-                $upt->track('status', $struseruptodate);
-                $usersuptodate++;
-
-                if ($bulk == UU_BULK_ALL) {
-                    if (!in_array($user->id, $SESSION->bulk_users)) {
-                        $SESSION->bulk_users[] = $user->id;
-                    }
-                }
-            }
-
-            if ($dologout) {
-                \core\session\manager::kill_user_sessions($existinguser->id);
-            }
-
-        } else {
-            // save the new user to the database
-            $user->confirmed    = 1;
-            $user->timemodified = time();
-            $user->timecreated  = time();
-            $user->mnethostid   = $CFG->mnet_localhost_id; // we support ONLY local accounts here, sorry
-
-            if (!isset($user->suspended) or $user->suspended === '') {
-                $user->suspended = 0;
-            } else {
-                $user->suspended = $user->suspended ? 1 : 0;
-            }
-            $upt->track('suspended', $stryesnooptions[$user->suspended], 'normal', false);
-
-            if (empty($user->auth)) {
-                $user->auth = 'manual';
-            }
-            $upt->track('auth', $user->auth, 'normal', false);
-
-            // do not insert record if new auth plugin does not exist!
-            try {
-                $auth = get_auth_plugin($user->auth);
-            } catch (Exception $e) {
-                $upt->track('auth', get_string('userautherror', 'error', s($user->auth)), 'error');
-                $upt->track('status', $strusernotaddederror, 'error');
-                $userserrors++;
-                continue;
-            }
-            if (!isset($supportedauths[$user->auth])) {
-                $upt->track('auth', $struserauthunsupported, 'warning');
-            }
-
-            $isinternalauth = $auth->is_internal();
-
-            if (empty($user->email)) {
-                $upt->track('email', get_string('invalidemail'), 'error');
-                $upt->track('status', $strusernotaddederror, 'error');
-                $userserrors++;
-                continue;
-
-            } else if ($DB->record_exists('user', array('email'=>$user->email))) {
-                if ($noemailduplicates) {
-                    $upt->track('email', $stremailduplicate, 'error');
-                    $upt->track('status', $strusernotaddederror, 'error');
-                    $userserrors++;
-                    continue;
-                } else {
-                    $upt->track('email', $stremailduplicate, 'warning');
-                }
-            }
-            if (!validate_email($user->email)) {
-                $upt->track('email', get_string('invalidemail'), 'warning');
-            }
-
-            if (empty($user->lang)) {
-                $user->lang = '';
-            } else if (core_user::clean_field($user->lang, 'lang') === '') {
-                $upt->track('status', get_string('cannotfindlang', 'error', $user->lang), 'warning');
-                $user->lang = '';
-            }
-
-            $forcechangepassword = false;
-
-            if ($isinternalauth) {
-                if (empty($user->password)) {
-                    if ($createpasswords) {
-                        $user->password = 'to be generated';
-                        $upt->track('password', '', 'normal', false);
-                        $upt->track('password', get_string('uupasswordcron', 'tool_uploaduser'), 'warning', false);
-                    } else {
-                        $upt->track('password', '', 'normal', false);
-                        $upt->track('password', get_string('missingfield', 'error', 'password'), 'error');
-                        $upt->track('status', $strusernotaddederror, 'error');
-                        $userserrors++;
-                        continue;
-                    }
-                } else {
-                    $errmsg = null;
-                    $weak = !check_password_policy($user->password, $errmsg, $user);
-                    if ($resetpasswords == UU_PWRESET_ALL or ($resetpasswords == UU_PWRESET_WEAK and $weak)) {
-                        if ($weak) {
-                            $weakpasswords++;
-                            $upt->track('password', $strinvalidpasswordpolicy, 'warning');
-                        }
-                        $forcechangepassword = true;
-                    }
-                    // Use a low cost factor when generating bcrypt hash otherwise
-                    // hashing would be slow when uploading lots of users. Hashes
-                    // will be automatically updated to a higher cost factor the first
-                    // time the user logs in.
-                    $user->password = hash_internal_user_password($user->password, true);
-                }
-            } else {
-                $user->password = AUTH_PASSWORD_NOT_CACHED;
-                $upt->track('password', '-', 'normal', false);
-            }
-
-            $user->id = user_create_user($user, false, false);
-            $upt->track('username', html_writer::link(new moodle_url('/user/profile.php', array('id'=>$user->id)), s($user->username)), 'normal', false);
-
-            // pre-process custom profile menu fields data from csv file
-            $user = uu_pre_process_custom_profile_data($user);
-            // save custom profile fields data
-            profile_save_data($user);
-
-            if ($forcechangepassword) {
-                set_user_preference('auth_forcepasswordchange', 1, $user);
-            }
-            if ($user->password === 'to be generated') {
-                set_user_preference('create_password', 1, $user);
-            }
-
-            // Trigger event.
-            \core\event\user_created::create_from_userid($user->id)->trigger();
-
-            $upt->track('status', $struseradded);
-            $upt->track('id', $user->id, 'normal', false);
-            $usersnew++;
-
-            // make sure user context exists
-            context_user::instance($user->id);
-
-            if ($bulk == UU_BULK_NEW or $bulk == UU_BULK_ALL) {
-                if (!in_array($user->id, $SESSION->bulk_users)) {
-                    $SESSION->bulk_users[] = $user->id;
-                }
-            }
-        }
-
-        // Update user interests.
-        if (isset($user->interests) && strval($user->interests) !== '') {
-            useredit_update_interests($user, preg_split('/\s*,\s*/', $user->interests, -1, PREG_SPLIT_NO_EMPTY));
-        }
-
-        // add to cohort first, it might trigger enrolments indirectly - do NOT create cohorts here!
-        foreach ($filecolumns as $column) {
-            if (!preg_match('/^cohort\d+$/', $column)) {
-                continue;
-            }
-
-            if (!empty($user->$column)) {
-                $addcohort = $user->$column;
-                if (!isset($cohorts[$addcohort])) {
-                    if (is_number($addcohort)) {
-                        // only non-numeric idnumbers!
-                        $cohort = $DB->get_record('cohort', array('id'=>$addcohort));
-                    } else {
-                        $cohort = $DB->get_record('cohort', array('idnumber'=>$addcohort));
-                        if (empty($cohort) && has_capability('moodle/cohort:manage', context_system::instance())) {
-                            // Cohort was not found. Create a new one.
-                            $cohortid = cohort_add_cohort((object)array(
-                                'idnumber' => $addcohort,
-                                'name' => $addcohort,
-                                'contextid' => context_system::instance()->id
-                            ));
-                            $cohort = $DB->get_record('cohort', array('id'=>$cohortid));
-                        }
-                    }
-
-                    if (empty($cohort)) {
-                        $cohorts[$addcohort] = get_string('unknowncohort', 'core_cohort', s($addcohort));
-                    } else if (!empty($cohort->component)) {
-                        // cohorts synchronised with external sources must not be modified!
-                        $cohorts[$addcohort] = get_string('external', 'core_cohort');
-                    } else {
-                        $cohorts[$addcohort] = $cohort;
-                    }
-                }
-
-                if (is_object($cohorts[$addcohort])) {
-                    $cohort = $cohorts[$addcohort];
-                    if (!$DB->record_exists('cohort_members', array('cohortid'=>$cohort->id, 'userid'=>$user->id))) {
-                        cohort_add_member($cohort->id, $user->id);
-                        // we might add special column later, for now let's abuse enrolments
-                        $upt->track('enrolments', get_string('useradded', 'core_cohort', s($cohort->name)));
-                    }
-                } else {
-                    // error message
-                    $upt->track('enrolments', $cohorts[$addcohort], 'error');
-                }
-            }
-        }
-
-
-        // find course enrolments, groups, roles/types and enrol periods
-        // this is again a special case, we always do this for any updated or created users
-        foreach ($filecolumns as $column) {
-            if (preg_match('/^sysrole\d+$/', $column)) {
-
-                if (!empty($user->$column)) {
-                    $sysrolename = $user->$column;
-                    if ($sysrolename[0] == '-') {
-                        $removing = true;
-                        $sysrolename = substr($sysrolename, 1);
-                    } else {
-                        $removing = false;
-                    }
-
-                    if (array_key_exists($sysrolename, $sysrolecache)) {
-                        $sysroleid = $sysrolecache[$sysrolename]->id;
-                    } else {
-                        $upt->track('enrolments', get_string('unknownrole', 'error', s($sysrolename)), 'error');
-                        continue;
-                    }
-
-                    if ($removing) {
-                        if (user_has_role_assignment($user->id, $sysroleid, SYSCONTEXTID)) {
-                            role_unassign($sysroleid, $user->id, SYSCONTEXTID);
-                            $upt->track('enrolments', get_string('unassignedsysrole',
-                                    'tool_uploaduser', $sysrolecache[$sysroleid]->name));
-                        }
-                    } else {
-                        if (!user_has_role_assignment($user->id, $sysroleid, SYSCONTEXTID)) {
-                            role_assign($sysroleid, $user->id, SYSCONTEXTID);
-                            $upt->track('enrolments', get_string('assignedsysrole',
-                                    'tool_uploaduser', $sysrolecache[$sysroleid]->name));
-                        }
-                    }
-                }
-
-                continue;
-            }
-            if (!preg_match('/^course\d+$/', $column)) {
-                continue;
-            }
-            $i = substr($column, 6);
-
-            if (empty($user->{'course'.$i})) {
-                continue;
-            }
-            $shortname = $user->{'course'.$i};
-            if (!array_key_exists($shortname, $ccache)) {
-                if (!$course = $DB->get_record('course', array('shortname'=>$shortname), 'id, shortname')) {
-                    $upt->track('enrolments', get_string('unknowncourse', 'error', s($shortname)), 'error');
-                    continue;
-                }
-                $ccache[$shortname] = $course;
-                $ccache[$shortname]->groups = null;
-            }
-            $courseid      = $ccache[$shortname]->id;
-            $coursecontext = context_course::instance($courseid);
-            if (!isset($manualcache[$courseid])) {
-                $manualcache[$courseid] = false;
-                if ($manual) {
-                    if ($instances = enrol_get_instances($courseid, false)) {
-                        foreach ($instances as $instance) {
-                            if ($instance->enrol === 'manual') {
-                                $manualcache[$courseid] = $instance;
-                                break;
-                            }
-                        }
-                    }
-                }
-            }
-
-            if ($courseid == SITEID) {
-                // Technically frontpage does not have enrolments, but only role assignments,
-                // let's not invent new lang strings here for this rarely used feature.
-
-                if (!empty($user->{'role'.$i})) {
-                    $rolename = $user->{'role'.$i};
-                    if (array_key_exists($rolename, $rolecache)) {
-                        $roleid = $rolecache[$rolename]->id;
-                    } else {
-                        $upt->track('enrolments', get_string('unknownrole', 'error', s($rolename)), 'error');
-                        continue;
-                    }
-
-                    role_assign($roleid, $user->id, context_course::instance($courseid));
-
-                    $a = new stdClass();
-                    $a->course = $shortname;
-                    $a->role   = $rolecache[$roleid]->name;
-                    $upt->track('enrolments', get_string('enrolledincourserole', 'enrol_manual', $a));
-                }
-
-            } else if ($manual and $manualcache[$courseid]) {
-
-                // find role
-                $roleid = false;
-                if (!empty($user->{'role'.$i})) {
-                    $rolename = $user->{'role'.$i};
-                    if (array_key_exists($rolename, $rolecache)) {
-                        $roleid = $rolecache[$rolename]->id;
-                    } else {
-                        $upt->track('enrolments', get_string('unknownrole', 'error', s($rolename)), 'error');
-                        continue;
-                    }
-
-                } else if (!empty($user->{'type'.$i})) {
-                    // if no role, then find "old" enrolment type
-                    $addtype = $user->{'type'.$i};
-                    if ($addtype < 1 or $addtype > 3) {
-                        $upt->track('enrolments', $strerror.': typeN = 1|2|3', 'error');
-                        continue;
-                    } else if (empty($formdata->{'uulegacy'.$addtype})) {
-                        continue;
-                    } else {
-                        $roleid = $formdata->{'uulegacy'.$addtype};
-                    }
-                } else {
-                    // no role specified, use the default from manual enrol plugin
-                    $roleid = $manualcache[$courseid]->roleid;
-                }
-
-                if ($roleid) {
-                    // Find duration and/or enrol status.
-                    $timeend = 0;
-                    $timestart = $today;
-                    $status = null;
-
-                    if (isset($user->{'enrolstatus'.$i})) {
-                        $enrolstatus = $user->{'enrolstatus'.$i};
-                        if ($enrolstatus == '') {
-                            $status = null;
-                        } else if ($enrolstatus === (string)ENROL_USER_ACTIVE) {
-                            $status = ENROL_USER_ACTIVE;
-                        } else if ($enrolstatus === (string)ENROL_USER_SUSPENDED) {
-                            $status = ENROL_USER_SUSPENDED;
-                        } else {
-                            debugging('Unknown enrolment status.');
-                        }
-                    }
-
-                    if (!empty($user->{'enroltimestart'.$i})) {
-                        $parsedtimestart = strtotime($user->{'enroltimestart'.$i});
-                        if ($parsedtimestart !== false) {
-                            $timestart = $parsedtimestart;
-                        }
-                    }
-
-                    if (!empty($user->{'enrolperiod'.$i})) {
-                        $duration = (int)$user->{'enrolperiod'.$i} * 60*60*24; // convert days to seconds
-                        if ($duration > 0) { // sanity check
-                            $timeend = $timestart + $duration;
-                        }
-                    } else if ($manualcache[$courseid]->enrolperiod > 0) {
-                        $timeend = $timestart + $manualcache[$courseid]->enrolperiod;
-                    }
-
-                    $manual->enrol_user($manualcache[$courseid], $user->id, $roleid, $timestart, $timeend, $status);
-
-                    $a = new stdClass();
-                    $a->course = $shortname;
-                    $a->role   = $rolecache[$roleid]->name;
-                    $upt->track('enrolments', get_string('enrolledincourserole', 'enrol_manual', $a));
-                }
-            }
-
-            // find group to add to
-            if (!empty($user->{'group'.$i})) {
-                // make sure user is enrolled into course before adding into groups
-                if (!is_enrolled($coursecontext, $user->id)) {
-                    $upt->track('enrolments', get_string('addedtogroupnotenrolled', '', $user->{'group'.$i}), 'error');
-                    continue;
-                }
-                //build group cache
-                if (is_null($ccache[$shortname]->groups)) {
-                    $ccache[$shortname]->groups = array();
-                    if ($groups = groups_get_all_groups($courseid)) {
-                        foreach ($groups as $gid=>$group) {
-                            $ccache[$shortname]->groups[$gid] = new stdClass();
-                            $ccache[$shortname]->groups[$gid]->id   = $gid;
-                            $ccache[$shortname]->groups[$gid]->name = $group->name;
-                            if (!is_numeric($group->name)) { // only non-numeric names are supported!!!
-                                $ccache[$shortname]->groups[$group->name] = new stdClass();
-                                $ccache[$shortname]->groups[$group->name]->id   = $gid;
-                                $ccache[$shortname]->groups[$group->name]->name = $group->name;
-                            }
-                        }
-                    }
-                }
-                // group exists?
-                $addgroup = $user->{'group'.$i};
-                if (!array_key_exists($addgroup, $ccache[$shortname]->groups)) {
-                    // if group doesn't exist,  create it
-                    $newgroupdata = new stdClass();
-                    $newgroupdata->name = $addgroup;
-                    $newgroupdata->courseid = $ccache[$shortname]->id;
-                    $newgroupdata->description = '';
-                    $gid = groups_create_group($newgroupdata);
-                    if ($gid){
-                        $ccache[$shortname]->groups[$addgroup] = new stdClass();
-                        $ccache[$shortname]->groups[$addgroup]->id   = $gid;
-                        $ccache[$shortname]->groups[$addgroup]->name = $newgroupdata->name;
-                    } else {
-                        $upt->track('enrolments', get_string('unknowngroup', 'error', s($addgroup)), 'error');
-                        continue;
-                    }
-                }
-                $gid   = $ccache[$shortname]->groups[$addgroup]->id;
-                $gname = $ccache[$shortname]->groups[$addgroup]->name;
-
-                try {
-                    if (groups_add_member($gid, $user->id)) {
-                        $upt->track('enrolments', get_string('addedtogroup', '', s($gname)));
-                    }  else {
-                        $upt->track('enrolments', get_string('addedtogroupnot', '', s($gname)), 'error');
-                    }
-                } catch (moodle_exception $e) {
-                    $upt->track('enrolments', get_string('addedtogroupnot', '', s($gname)), 'error');
-                    continue;
-                }
-            }
-        }
-        $validation[$user->username] = core_user::validate($user);
-    }
-    $upt->close(); // close table
-    if (!empty($validation)) {
-        foreach ($validation as $username => $result) {
-            if ($result !== true) {
-                \core\notification::warning(get_string('invaliduserdata', 'tool_uploaduser', s($username)));
-            }
-        }
-    }
-    $cir->close();
-    $cir->cleanup(true);
+    $process->set_form_data($formdata);
+    $process->process();
 
     echo $OUTPUT->box_start('boxwidthnarrow boxaligncenter generalbox', 'uploadresults');
-    echo '<p>';
-    if ($optype != UU_USER_UPDATE) {
-        echo get_string('userscreated', 'tool_uploaduser').': '.$usersnew.'<br />';
-    }
-    if ($optype == UU_USER_UPDATE or $optype == UU_USER_ADD_UPDATE) {
-        echo get_string('usersupdated', 'tool_uploaduser').': '.$usersupdated.'<br />';
-    }
-    if ($allowdeletes) {
-        echo get_string('usersdeleted', 'tool_uploaduser').': '.$deletes.'<br />';
-        echo get_string('deleteerrors', 'tool_uploaduser').': '.$deleteerrors.'<br />';
-    }
-    if ($allowrenames) {
-        echo get_string('usersrenamed', 'tool_uploaduser').': '.$renames.'<br />';
-        echo get_string('renameerrors', 'tool_uploaduser').': '.$renameerrors.'<br />';
-    }
-    if ($usersskipped) {
-        echo get_string('usersskipped', 'tool_uploaduser').': '.$usersskipped.'<br />';
-    }
-    echo get_string('usersweakpassword', 'tool_uploaduser').': '.$weakpasswords.'<br />';
-    echo get_string('errors', 'tool_uploaduser').': '.$userserrors.'</p>';
+    echo html_writer::tag('p', join('<br />', $process->get_stats()));
     echo $OUTPUT->box_end();
 
-    if ($bulk) {
+    if ($process->get_bulk()) {
         echo $OUTPUT->continue_button($bulknurl);
     } else {
         echo $OUTPUT->continue_button($returnurl);
@@ -1181,92 +104,22 @@ if ($formdata = $mform2->is_cancelled()) {
     die;
 }
 
-// Print the header
+// Print the header.
 echo $OUTPUT->header();
 
 echo $OUTPUT->heading(get_string('uploaduserspreview', 'tool_uploaduser'));
 
 // NOTE: this is JUST csv processing preview, we must not prevent import from here if there is something in the file!!
-//       this was intended for validation of csv formatting and encoding, not filtering the data!!!!
-//       we definitely must not process the whole file!
+// this was intended for validation of csv formatting and encoding, not filtering the data!!!!
+// we definitely must not process the whole file!
 
-// preview table data
-$data = array();
-$cir->init();
-$linenum = 1; //column header is first line
-$noerror = true; // Keep status of any error.
-while ($linenum <= $previewrows and $fields = $cir->next()) {
-    $linenum++;
-    $rowcols = array();
-    $rowcols['line'] = $linenum;
-    foreach($fields as $key => $field) {
-        $rowcols[$filecolumns[$key]] = s(trim($field));
-    }
-    $rowcols['status'] = array();
-
-    if (isset($rowcols['username'])) {
-        $stdusername = core_user::clean_field($rowcols['username'], 'username');
-        if ($rowcols['username'] !== $stdusername) {
-            $rowcols['status'][] = get_string('invalidusernameupload');
-        }
-        if ($userid = $DB->get_field('user', 'id', array('username'=>$stdusername, 'mnethostid'=>$CFG->mnet_localhost_id))) {
-            $rowcols['username'] = html_writer::link(new moodle_url('/user/profile.php', array('id'=>$userid)), $rowcols['username']);
-        }
-    } else {
-        $rowcols['status'][] = get_string('missingusername');
-    }
-
-    if (isset($rowcols['email'])) {
-        if (!validate_email($rowcols['email'])) {
-            $rowcols['status'][] = get_string('invalidemail');
-        }
-
-        $select = $DB->sql_like('email', ':email', false, true, false, '|');
-        $params = array('email' => $DB->sql_like_escape($rowcols['email'], '|'));
-        if ($DB->record_exists_select('user', $select , $params)) {
-            $rowcols['status'][] = $stremailduplicate;
-        }
-    }
-
-    if (isset($rowcols['city'])) {
-        $rowcols['city'] = $rowcols['city'];
-    }
-
-    if (isset($rowcols['theme'])) {
-        list($status, $message) = field_value_validators::validate_theme($rowcols['theme']);
-        if ($status !== 'normal' && !empty($message)) {
-            $rowcols['status'][] = $message;
-        }
-    }
-
-    // Check if rowcols have custom profile field with correct data and update error state.
-    $noerror = uu_check_custom_profile_data($rowcols) && $noerror;
-    $rowcols['status'] = implode('<br />', $rowcols['status']);
-    $data[] = $rowcols;
-}
-if ($fields = $cir->next()) {
-    $data[] = array_fill(0, count($fields) + 2, '...');
-}
-$cir->close();
-
-$table = new html_table();
-$table->id = "uupreview";
-$table->attributes['class'] = 'generaltable';
-$table->tablealign = 'center';
-$table->summary = get_string('uploaduserspreview', 'tool_uploaduser');
-$table->head = array();
-$table->data = $data;
-
-$table->head[] = get_string('uucsvline', 'tool_uploaduser');
-foreach ($filecolumns as $column) {
-    $table->head[] = $column;
-}
-$table->head[] = get_string('status');
+// Preview table data.
+$table = new \tool_uploaduser\preview($cir, $filecolumns, $previewrows);
 
-echo html_writer::tag('div', html_writer::table($table), array('class'=>'flexible-wrap'));
+echo html_writer::tag('div', html_writer::table($table), ['class' => 'flexible-wrap']);
 
-// Print the form if valid values are available
-if ($noerror) {
+// Print the form if valid values are available.
+if ($table->get_no_error()) {
     $mform2->display();
 }
 echo $OUTPUT->footer();
index 6e875e6..9f65fa5 100644 (file)
@@ -27,19 +27,30 @@ $string['allowdeletes'] = 'Allow deletes';
 $string['allowrenames'] = 'Allow renames';
 $string['allowsuspends'] = 'Allow suspending and activating of accounts';
 $string['assignedsysrole'] = 'Assigned system role {$a}';
+$string['clidefault'] = 'Default:';
+$string['clierrorargument'] = 'Value for argument --{$a->name} is not valid. Allowed values: {$a->values}';
+$string['clifile'] = 'Path to CSV file with the user data. Required.';
+$string['clifilenotreadable'] = 'File {$a} does not exist or is not readable';
+$string['clihelp'] = 'Print out this help.';
+$string['climissingargument'] = 'Argument --{$a} is required';
+$string['clititle'] = 'Command line Upload user tool.';
+$string['clivalidationerror'] = 'Validation error:';
 $string['csvdelimiter'] = 'CSV delimiter';
 $string['defaultvalues'] = 'Default values';
 $string['deleteerrors'] = 'Delete errors';
 $string['encoding'] = 'Encoding';
 $string['errormnetadd'] = 'Can not add remote users';
+$string['errorprefix'] = 'Error:';
 $string['errors'] = 'Errors';
 $string['examplecsv'] = 'Example text file';
 $string['examplecsv_help'] = 'To use the example text file, download it then open it with a text or spreadsheet editor. Leave the first line unchanged, then edit the following lines (records) and add your user data, adding more lines as necessary. Save the file as CSV then upload it.
 
 The example text file may also be used for testing, as you are able to preview user data and can choose to cancel the action before user accounts are created.';
+$string['infoprefix'] = 'Info:';
 $string['invalidupdatetype'] = 'This option cannot be selected with the chosen upload type.';
 $string['invaliduserdata'] = 'Invalid data detected for user {$a} and it has been automatically cleaned.';
 $string['invalidtheme'] = 'Theme "{$a}" is not installed and will be ignored.';
+$string['linex'] = 'Line {$a}';
 $string['nochanges'] = 'No changes';
 $string['notheme'] = 'No theme is defined for this user.';
 $string['pluginname'] = 'User upload';
@@ -106,3 +117,4 @@ $string['uuupdatemissing'] = 'Fill in missing from file and defaults';
 $string['uuupdatetype'] = 'Existing user details';
 $string['uuusernametemplate'] = 'Username template';
 $string['privacy:metadata'] = 'The User upload plugin does not store any personal data.';
+$string['warningprefix'] = 'Warning:';
index 46b8bc4..68481c7 100644 (file)
@@ -55,14 +55,38 @@ define('UU_PWRESET_ALL', 2);
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
 class uu_progress_tracker {
-    private $_row;
+    /** @var array */
+    protected $_row;
 
     /**
      * The columns shown on the table.
      * @var array
      */
-    public $columns = array('status', 'line', 'id', 'username', 'firstname', 'lastname', 'email',
-                            'password', 'auth', 'enrolments', 'suspended', 'theme', 'deleted');
+    public $columns = [];
+    /** @var array column headers */
+    protected $headers = [];
+
+    /**
+     * uu_progress_tracker constructor.
+     */
+    public function __construct() {
+        $this->headers = [
+            'status' => get_string('status'),
+            'line' => get_string('uucsvline', 'tool_uploaduser'),
+            'id' => 'ID',
+            'username' => get_string('username'),
+            'firstname' => get_string('firstname'),
+            'lastname' => get_string('lastname'),
+            'email' => get_string('email'),
+            'password' => get_string('password'),
+            'auth' => get_string('authentication'),
+            'enrolments' => get_string('enrolments', 'enrol'),
+            'suspended' => get_string('suspended', 'auth'),
+            'theme' => get_string('theme'),
+            'deleted' => get_string('delete'),
+        ];
+        $this->columns = array_keys($this->headers);
+    }
 
     /**
      * Print table header.
@@ -72,19 +96,9 @@ class uu_progress_tracker {
         $ci = 0;
         echo '<table id="uuresults" class="generaltable boxaligncenter flexible-wrap" summary="'.get_string('uploadusersresult', 'tool_uploaduser').'">';
         echo '<tr class="heading r0">';
-        echo '<th class="header c'.$ci++.'" scope="col">'.get_string('status').'</th>';
-        echo '<th class="header c'.$ci++.'" scope="col">'.get_string('uucsvline', 'tool_uploaduser').'</th>';
-        echo '<th class="header c'.$ci++.'" scope="col">ID</th>';
-        echo '<th class="header c'.$ci++.'" scope="col">'.get_string('username').'</th>';
-        echo '<th class="header c'.$ci++.'" scope="col">'.get_string('firstname').'</th>';
-        echo '<th class="header c'.$ci++.'" scope="col">'.get_string('lastname').'</th>';
-        echo '<th class="header c'.$ci++.'" scope="col">'.get_string('email').'</th>';
-        echo '<th class="header c'.$ci++.'" scope="col">'.get_string('password').'</th>';
-        echo '<th class="header c'.$ci++.'" scope="col">'.get_string('authentication').'</th>';
-        echo '<th class="header c'.$ci++.'" scope="col">'.get_string('enrolments', 'enrol').'</th>';
-        echo '<th class="header c'.$ci++.'" scope="col">'.get_string('suspended', 'auth').'</th>';
-        echo '<th class="header c'.$ci++.'" scope="col">'.get_string('theme').'</th>';
-        echo '<th class="header c'.$ci++.'" scope="col">'.get_string('delete').'</th>';
+        foreach ($this->headers as $key => $header) {
+            echo '<th class="header c'.$ci++.'" scope="col">'.$header.'</th>';
+        }
         echo '</tr>';
         $this->_row = null;
     }
diff --git a/admin/tool/uploaduser/tests/cli_test.php b/admin/tool/uploaduser/tests/cli_test.php
new file mode 100644 (file)
index 0000000..a13c047
--- /dev/null
@@ -0,0 +1,295 @@
+<?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/>.
+
+/**
+ * Tests for CLI tool_uploaduser.
+ *
+ * @package    tool_uploaduser
+ * @copyright  2020 Marina Glancy
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+use \tool_uploaduser\cli_helper;
+
+/**
+ * Tests for CLI tool_uploaduser.
+ *
+ * @package    tool_uploaduser
+ * @copyright  2020 Marina Glancy
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class tool_uploaduser_cli_testcase extends advanced_testcase {
+
+    /**
+     * Generate cli_helper and mock $_SERVER['argv']
+     *
+     * @param array $mockargv
+     * @return \tool_uploaduser\cli_helper
+     */
+    protected function construct_helper(array $mockargv = []) {
+        if (array_key_exists('argv', $_SERVER)) {
+            $oldservervars = $_SERVER['argv'];
+        }
+        $_SERVER['argv'] = array_merge([''], $mockargv);
+        $clihelper = new cli_helper(\tool_uploaduser\local\text_progress_tracker::class);
+        if (isset($oldservervars)) {
+            $_SERVER['argv'] = $oldservervars;
+        } else {
+            unset($_SERVER['argv']);
+        }
+        return $clihelper;
+    }
+
+    /**
+     * Tests simple upload with course enrolment and group allocation
+     */
+    public function test_upload_with_course_enrolment() {
+        global $CFG;
+        $this->resetAfterTest();
+        set_config('passwordpolicy', 0);
+        $this->setAdminUser();
+
+        $course = $this->getDataGenerator()->create_course(['fullname' => 'Maths', 'shortname' => 'math102']);
+        $g1 = $this->getDataGenerator()->create_group(['courseid' => $course->id, 'name' => 'Section 1', 'idnumber' => 'S1']);
+        $g2 = $this->getDataGenerator()->create_group(['courseid' => $course->id, 'name' => 'Section 3', 'idnumber' => 'S3']);
+
+        $filepath = $CFG->dirroot.'/lib/tests/fixtures/upload_users.csv';
+
+        $clihelper = $this->construct_helper(["--file=$filepath"]);
+        ob_start();
+        $clihelper->process();
+        $output = ob_get_contents();
+        ob_end_clean();
+
+        // CLI output suggests that 2 users were created.
+        $stats = $clihelper->get_stats();
+        $this->assertEquals(2, preg_match_all('/New user/', $output));
+        $this->assertEquals('Users created: 2', $stats[0]);
+
+        // Tom Jones and Trent Reznor are enrolled into the course, first one to group $g1 and second to group $g2.
+        $enrols = array_values(enrol_get_course_users($course->id));
+        $this->assertEqualsCanonicalizing(['reznor', 'jonest'], [$enrols[0]->username, $enrols[1]->username]);
+        $g1members = groups_get_groups_members($g1->id);
+        $this->assertEquals(1, count($g1members));
+        $this->assertEquals('Jones', $g1members[key($g1members)]->lastname);
+        $g2members = groups_get_groups_members($g2->id);
+        $this->assertEquals(1, count($g2members));
+        $this->assertEquals('Reznor', $g2members[key($g2members)]->lastname);
+    }
+
+    /**
+     * Test applying defaults during the user upload
+     */
+    public function test_upload_with_applying_defaults() {
+        global $CFG;
+        $this->resetAfterTest();
+        set_config('passwordpolicy', 0);
+        $this->setAdminUser();
+
+        $course = $this->getDataGenerator()->create_course(['fullname' => 'Maths', 'shortname' => 'math102']);
+        $g1 = $this->getDataGenerator()->create_group(['courseid' => $course->id, 'name' => 'Section 1', 'idnumber' => 'S1']);
+        $g2 = $this->getDataGenerator()->create_group(['courseid' => $course->id, 'name' => 'Section 3', 'idnumber' => 'S3']);
+
+        $filepath = $CFG->dirroot.'/lib/tests/fixtures/upload_users.csv';
+
+        $clihelper = $this->construct_helper(["--file=$filepath", '--city=Brighton', '--department=Purchasing']);
+        ob_start();
+        $clihelper->process();
+        $output = ob_get_contents();
+        ob_end_clean();
+
+        // CLI output suggests that 2 users were created.
+        $stats = $clihelper->get_stats();
+        $this->assertEquals(2, preg_match_all('/New user/', $output));
+        $this->assertEquals('Users created: 2', $stats[0]);
+
+        // Users have default values applied.
+        $user1 = core_user::get_user_by_username('jonest');
+        $this->assertEquals('Brighton', $user1->city);
+        $this->assertEquals('Purchasing', $user1->department);
+    }
+
+    /**
+     * User upload with user profile fields
+     */
+    public function test_upload_with_profile_fields() {
+        global $DB, $CFG;
+        $this->resetAfterTest();
+        set_config('passwordpolicy', 0);
+        $this->setAdminUser();
+
+        $categoryid = $DB->insert_record('user_info_category', ['name' => 'Cat 1', 'sortorder' => 1]);
+        $this->field1 = $DB->insert_record('user_info_field', [
+            'shortname' => 'superfield', 'name' => 'Super field', 'categoryid' => $categoryid,
+            'datatype' => 'text', 'signup' => 1, 'visible' => 1, 'required' => 1, 'sortorder' => 1]);
+
+        $filepath = $CFG->dirroot.'/lib/tests/fixtures/upload_users_profile.csv';
+
+        $clihelper = $this->construct_helper(["--file=$filepath"]);
+        ob_start();
+        $clihelper->process();
+        $output = ob_get_contents();
+        ob_end_clean();
+
+        // CLI output suggests that 2 users were created.
+        $stats = $clihelper->get_stats();
+        $this->assertEquals(2, preg_match_all('/New user/', $output));
+        $this->assertEquals('Users created: 2', $stats[0]);
+
+        // Created users have data in the profile fields.
+        $user1 = core_user::get_user_by_username('reznort');
+        $profilefields1 = profile_user_record($user1->id);
+        $this->assertEquals((object)['superfield' => 'Loves cats'], $profilefields1);
+    }
+
+    /**
+     * Testing that help for CLI does not throw errors
+     */
+    public function test_cli_help() {
+        $this->resetAfterTest();
+        $this->setAdminUser();
+        $clihelper = $this->construct_helper(["--help"]);
+        ob_start();
+        $clihelper->print_help();
+        $output = ob_get_contents();
+        ob_end_clean();
+
+        // Basically a test that everything can be parsed and displayed without errors. Check that some options are present.
+        $this->assertEquals(1, preg_match('/--delimiter_name=VALUE/', $output));
+        $this->assertEquals(1, preg_match('/--uutype=VALUE/', $output));
+        $this->assertEquals(1, preg_match('/--auth=VALUE/', $output));
+    }
+
+    /**
+     * Testing skipped user when one exists
+     */
+    public function test_create_when_user_exists() {
+        global $CFG;
+        $this->resetAfterTest();
+        set_config('passwordpolicy', 0);
+        $this->setAdminUser();
+
+        $course = $this->getDataGenerator()->create_course(['fullname' => 'Maths', 'shortname' => 'math102']);
+        $g1 = $this->getDataGenerator()->create_group(['courseid' => $course->id, 'name' => 'Section 1', 'idnumber' => 'S1']);
+        $g2 = $this->getDataGenerator()->create_group(['courseid' => $course->id, 'name' => 'Section 3', 'idnumber' => 'S3']);
+
+        // Create a user with username jonest.
+        $user1 = $this->getDataGenerator()->create_user(['username' => 'jonest', 'email' => 'jonest@someplace.edu']);
+
+        $filepath = $CFG->dirroot.'/lib/tests/fixtures/upload_users.csv';
+
+        $clihelper = $this->construct_helper(["--file=$filepath"]);
+        ob_start();
+        $clihelper->process();
+        $output = ob_get_contents();
+        ob_end_clean();
+
+        // CLI output suggests that 1 user was created and 1 skipped.
+        $stats = $clihelper->get_stats();
+        $this->assertEquals(1, preg_match_all('/New user/', $output));
+        $this->assertEquals('Users created: 1', $stats[0]);
+        $this->assertEquals('Users skipped: 1', $stats[1]);
+
+        // Trent Reznor is enrolled into the course, Tom Jones is not!
+        $enrols = array_values(enrol_get_course_users($course->id));
+        $this->assertEqualsCanonicalizing(['reznor'], [$enrols[0]->username]);
+    }
+
+    /**
+     * Testing update mode - do not update user records but allow enrolments
+     */
+    public function test_enrolments_when_user_exists() {
+        global $CFG;
+        require_once($CFG->dirroot.'/'.$CFG->admin.'/tool/uploaduser/locallib.php');
+
+        $this->resetAfterTest();
+        set_config('passwordpolicy', 0);
+        $this->setAdminUser();
+
+        $course = $this->getDataGenerator()->create_course(['fullname' => 'Maths', 'shortname' => 'math102']);
+        $g1 = $this->getDataGenerator()->create_group(['courseid' => $course->id, 'name' => 'Section 1', 'idnumber' => 'S1']);
+        $g2 = $this->getDataGenerator()->create_group(['courseid' => $course->id, 'name' => 'Section 3', 'idnumber' => 'S3']);
+
+        // Create a user with username jonest.
+        $this->getDataGenerator()->create_user(['username' => 'jonest', 'email' => 'jonest@someplace.edu',
+            'firstname' => 'OLDNAME']);
+
+        $filepath = $CFG->dirroot.'/lib/tests/fixtures/upload_users.csv';
+
+        $clihelper = $this->construct_helper(["--file=$filepath", '--uutype='.UU_USER_UPDATE]);
+        ob_start();
+        $clihelper->process();
+        $output = ob_get_contents();
+        ob_end_clean();
+
+        // CLI output suggests that 1 user was created and 1 skipped.
+        $stats = $clihelper->get_stats();
+        $this->assertEquals(0, preg_match_all('/New user/', $output));
+        $this->assertEquals('Users updated: 0', $stats[0]);
+        $this->assertEquals('Users skipped: 1', $stats[1]);
+
+        // Tom Jones is enrolled into the course.
+        $enrols = array_values(enrol_get_course_users($course->id));
+        $this->assertEqualsCanonicalizing(['jonest'], [$enrols[0]->username]);
+        // User reznor is not created.
+        $this->assertFalse(core_user::get_user_by_username('reznor'));
+        // User jonest is not updated.
+        $this->assertEquals('OLDNAME', core_user::get_user_by_username('jonest')->firstname);
+    }
+
+    /**
+     * Testing update mode - update user records and perform enrolments.
+     */
+    public function test_udpate_user() {
+        global $CFG;
+        require_once($CFG->dirroot.'/'.$CFG->admin.'/tool/uploaduser/locallib.php');
+
+        $this->resetAfterTest();
+        set_config('passwordpolicy', 0);
+        $this->setAdminUser();
+
+        $course = $this->getDataGenerator()->create_course(['fullname' => 'Maths', 'shortname' => 'math102']);
+        $g1 = $this->getDataGenerator()->create_group(['courseid' => $course->id, 'name' => 'Section 1', 'idnumber' => 'S1']);
+        $g2 = $this->getDataGenerator()->create_group(['courseid' => $course->id, 'name' => 'Section 3', 'idnumber' => 'S3']);
+
+        // Create a user with username jonest.
+        $this->getDataGenerator()->create_user(['username' => 'jonest',
+            'email' => 'jonest@someplace.edu', 'firstname' => 'OLDNAME']);
+
+        $filepath = $CFG->dirroot.'/lib/tests/fixtures/upload_users.csv';
+
+        $clihelper = $this->construct_helper(["--file=$filepath", '--uutype='.UU_USER_UPDATE,
+            '--uuupdatetype='.UU_UPDATE_FILEOVERRIDE]);
+        ob_start();
+        $clihelper->process();
+        $output = ob_get_contents();
+        ob_end_clean();
+
+        // CLI output suggests that 1 user was created and 1 skipped.
+        $stats = $clihelper->get_stats();
+        $this->assertEquals(0, preg_match_all('/New user/', $output));
+        $this->assertEquals('Users updated: 1', $stats[0]);
+        $this->assertEquals('Users skipped: 1', $stats[1]);
+
+        // Tom Jones is enrolled into the course.
+        $enrols = array_values(enrol_get_course_users($course->id));
+        $this->assertEqualsCanonicalizing(['jonest'], [$enrols[0]->username]);
+        // User reznor is not created.
+        $this->assertFalse(core_user::get_user_by_username('reznor'));
+        // User jonest is updated, new first name is Tom.
+        $this->assertEquals('Tom', core_user::get_user_by_username('jonest')->firstname);
+    }
+}
index 552ea4c..0da4f64 100644 (file)
@@ -68,6 +68,18 @@ class admin_uploaduser_form1 extends moodleform {
 
         $this->add_action_buttons(false, get_string('uploadusers', 'tool_uploaduser'));
     }
+
+    /**
+     * Returns list of elements and their default values, to be used in CLI
+     *
+     * @return array
+     */
+    public function get_form_for_cli() {
+        $elements = array_filter($this->_form->_elements, function($element) {
+            return !in_array($element->getName(), ['buttonar', 'userfile', 'previewrows']);
+        });
+        return [$elements, $this->_form->_defaultValues];
+    }
 }
 
 
@@ -434,4 +446,25 @@ class admin_uploaduser_form2 extends moodleform {
 
         return $data;
     }
+
+    /**
+     * Returns list of elements and their default values, to be used in CLI
+     *
+     * @return array
+     */
+    public function get_form_for_cli() {
+        $elements = array_filter($this->_form->_elements, function($element) {
+            return !in_array($element->getName(), ['buttonar', 'uubulk']);
+        });
+        return [$elements, $this->_form->_defaultValues];
+    }
+
+    /**
+     * Returns validation errors (used in CLI)
+     *
+     * @return array
+     */
+    public function get_validation_errors(): array {
+        return $this->_form->_errors;
+    }
 }
index 3713d07..21b70de 100644 (file)
@@ -47,7 +47,7 @@ class provider implements
     /**
      * Returns meta data about this system.
      *
-     * @param   collection     $itemcollection The initialised item collection to add items to.
+     * @param   collection     $items The initialised item collection to add items to.
      * @return  collection     A listing of user data stored through this system.
      */
     public static function get_metadata(collection $items) : collection {
@@ -64,7 +64,7 @@ class provider implements
      * @param   int         $userid The userid of the user whose data is to be exported.
      */
     public static function export_user_preferences(int $userid) {
-        $preferences = get_user_preferences();
+        $preferences = get_user_preferences(null, null, $userid);
         foreach ($preferences as $name => $value) {
             $descriptionidentifier = null;
             $tourid = null;
index 4c4a201..0765ee3 100644 (file)
@@ -557,9 +557,10 @@ class tour {
 
         // Remove the configuration for the tour.
         $DB->delete_records('tool_usertours_tours', array('id' => $this->id));
-
         helper::reset_tour_sortorder();
 
+        $this->remove_user_preferences();
+
         return null;
     }
 
@@ -585,6 +586,16 @@ class tour {
         return $this;
     }
 
+    /**
+     * Remove stored user preferences for the tour
+     */
+    protected function remove_user_preferences(): void {
+        global $DB;
+
+        $DB->delete_records('user_preferences', ['name' => self::TOUR_LAST_COMPLETED_BY_USER . $this->get_id()]);
+        $DB->delete_records('user_preferences', ['name' => self::TOUR_REQUESTED_BY_USER . $this->get_id()]);
+    }
+
     /**
      * Whether this tour should be displayed to the user.
      *
@@ -665,11 +676,9 @@ class tour {
      * @return  $this
      */
     public function mark_major_change() {
-        global $DB;
-
         // Clear old reset and completion notes.
-        $DB->delete_records('user_preferences', ['name' => self::TOUR_LAST_COMPLETED_BY_USER . $this->get_id()]);
-        $DB->delete_records('user_preferences', ['name' => self::TOUR_REQUESTED_BY_USER . $this->get_id()]);
+        $this->remove_user_preferences();
+
         $this->set_config('majorupdatetime', time());
         $this->persist();
 
index 6769e01..e3c0fc9 100644 (file)
@@ -25,6 +25,7 @@
 defined('MOODLE_INTERNAL') || die();
 
 use tool_usertours\manager;
+use tool_usertours\tour;
 
 /**
  * Upgrade the user tours plugin.
@@ -57,5 +58,26 @@ function xmldb_tool_usertours_upgrade($oldversion) {
     // Automatically generated Moodle v3.9.0 release upgrade line.
     // Put any upgrade step following this.
 
+    if ($oldversion < 2020082700) {
+        // Clean up user preferences of deleted tours.
+        $select = $DB->sql_like('name', ':lastcompleted') . ' OR ' . $DB->sql_like('name', ':requested');
+        $params = [
+            'lastcompleted' => tour::TOUR_LAST_COMPLETED_BY_USER . '%',
+            'requested' => tour::TOUR_REQUESTED_BY_USER . '%',
+        ];
+
+        $preferences = $DB->get_records_select('user_preferences', $select, $params, '', 'DISTINCT name');
+        foreach ($preferences as $preference) {
+            // Match tour ID at the end of the preference name, remove all of that preference type if tour ID doesn't exist.
+            if (preg_match('/(?<tourid>\d+)$/', $preference->name, $matches) &&
+                    !$DB->record_exists('tool_usertours_tours', ['id' => $matches['tourid']])) {
+
+                $DB->delete_records('user_preferences', ['name' => $preference->name]);
+            }
+        }
+
+        upgrade_plugin_savepoint(true, 2020082700, 'tool', 'usertours');
+    }
+
     return true;
 }
index 17138da..8e552e1 100644 (file)
@@ -15,9 +15,9 @@
 // along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
 
 /**
- * Unit tests for the block_html implementation of the privacy API.
+ * Unit tests for the tool_usertours implementation of the privacy API.
  *
- * @package    block_html
+ * @package    tool_usertours
  * @category   test
  * @copyright  2018 Andrew Nicols <andrew@nicols.co.uk>
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
@@ -27,19 +27,22 @@ defined('MOODLE_INTERNAL') || die();
 
 use \core_privacy\local\metadata\collection;
 use \core_privacy\local\request\writer;
-use \core_privacy\local\request\approved_contextlist;
-use \core_privacy\local\request\deletion_criteria;
 use \tool_usertours\tour;
 use \tool_usertours\privacy\provider;
 
 /**
- * Unit tests for the block_html implementation of the privacy API.
+ * Unit tests for the tool_usertours implementation of the privacy API.
  *
  * @copyright  2018 Andrew Nicols <andrew@nicols.co.uk>
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
-class tool_usertours_privacy_testcase extends \core_privacy\tests\provider_testcase {
+class tool_usertours_privacy_provider_testcase extends \core_privacy\tests\provider_testcase {
 
+    /**
+     * Helper method for creating a tour
+     *
+     * @return tour
+     */
     protected function create_test_tour(): tour {
         return (new tour())
             ->set_name('test_tour')
@@ -118,6 +121,37 @@ class tool_usertours_privacy_testcase extends \core_privacy\tests\provider_testc
         $this->assertCount(2, (array) $prefs);
     }
 
+    /**
+     * Make sure we are exporting preferences for the correct user
+     */
+    public function test_export_user_preferences_correct_user(): void {
+        $this->resetAfterTest();
+
+        $tour = $this->create_test_tour();
+
+        // Create test user, mark them as having completed the tour.
+        $user = $this->getDataGenerator()->create_user();
+        $this->setUser($user);
+        $tour->mark_user_completed();
+
+        // Switch to admin user, mark them as having reset the tour.
+        $this->setAdminUser();
+        $tour->request_user_reset();
+
+        // Export test users preferences.
+        provider::export_user_preferences($user->id);
+
+        $writer = writer::with_context(\context_system::instance());
+        $this->assertTrue($writer->has_any_data());
+
+        $prefs = $writer->get_user_preferences('tool_usertours');
+        $this->assertCount(1, (array) $prefs);
+
+        // We should have received back the "completed tour" preference of the test user.
+        $this->assertStringStartsWith('You last marked the "' . $tour->get_name() . '" user tour as completed on',
+            reset($prefs)->description);
+    }
+
     /**
      * Ensure that export_user_preferences excludes deleted tours.
      */
index d08a9df..c5bf23f 100644 (file)
@@ -64,7 +64,7 @@ class tour_testcase extends advanced_testcase {
     /**
      * Helper to mock the database.
      *
-     * @return moodle_database
+     * @return \PHPUnit\Framework\MockObject\MockObject
      */
     public function mock_database() {
         global $DB;
@@ -569,9 +569,14 @@ class tour_testcase extends advanced_testcase {
 
         // Mock the database.
         $DB = $this->mock_database();
-        $DB->expects($this->once())
+
+        $DB->expects($this->exactly(3))
             ->method('delete_records')
-            ->with($this->equalTo('tool_usertours_tours'), $this->equalTo(['id' => $id]))
+            ->withConsecutive(
+                [$this->equalTo('tool_usertours_tours'), $this->equalTo(['id' => $id])],
+                [$this->equalTo('user_preferences'), $this->equalTo(['name' => tour::TOUR_LAST_COMPLETED_BY_USER . $id])],
+                [$this->equalTo('user_preferences'), $this->equalTo(['name' => tour::TOUR_REQUESTED_BY_USER . $id])]
+            )
             ->willReturn(null)
             ;
 
index c477167..e34cc47 100644 (file)
@@ -24,6 +24,6 @@
 
 defined('MOODLE_INTERNAL') || die();
 
-$plugin->version   = 2020061501;            // The current module version (Date: YYYYMMDDXX).
+$plugin->version   = 2020082700;            // The current module version (Date: YYYYMMDDXX).
 $plugin->requires  = 2020060900;            // Requires this Moodle version.
 $plugin->component = 'tool_usertours';      // Full name of the plugin (used for diagnostics).
index e56c325..c7b77cc 100644 (file)
@@ -61,7 +61,7 @@ foreach ($allfunctions as $f) {
     }
 }
 
-// whitelisting security
+// Allow only functions available for testing.
 if (!isset($functions[$function])) {
     $function = '';
 }
@@ -81,7 +81,9 @@ foreach ($active_protocols as $p) {
     }
     $protocols[$p] = get_string('pluginname', 'webservice_'.$p);
 }
-if (!isset($protocols[$protocol])) { // whitelisting security
+
+// Allow only protocols supporting the test client.
+if (!isset($protocols[$protocol])) {
     $protocol = '';
 }