Merge branch 'MDL-61121-master-5' of git://github.com/mihailges/moodle
authorSara Arjona <sara@moodle.com>
Mon, 21 Oct 2019 12:46:17 +0000 (14:46 +0200)
committerSara Arjona <sara@moodle.com>
Mon, 21 Oct 2019 12:46:17 +0000 (14:46 +0200)
226 files changed:
admin/settings/courses.php
admin/tool/analytics/amd/build/potential-contexts.min.js [new file with mode: 0644]
admin/tool/analytics/amd/build/potential-contexts.min.js.map [new file with mode: 0644]
admin/tool/analytics/amd/src/potential-contexts.js [new file with mode: 0644]
admin/tool/analytics/classes/external.php [new file with mode: 0644]
admin/tool/analytics/classes/output/form/edit_model.php
admin/tool/analytics/classes/output/invalid_analysables.php
admin/tool/analytics/cli/evaluate_model.php
admin/tool/analytics/createmodel.php
admin/tool/analytics/db/services.php [new file with mode: 0644]
admin/tool/analytics/lang/en/tool_analytics.php
admin/tool/analytics/model.php
admin/tool/analytics/tests/external_test.php [new file with mode: 0644]
admin/tool/analytics/version.php
admin/tool/uploaduser/example.csv [new file with mode: 0644]
admin/tool/uploaduser/lang/en/tool_uploaduser.php
admin/tool/uploaduser/user_form.php
analytics/classes/analysis.php
analytics/classes/local/analyser/base.php
analytics/classes/local/analyser/by_course.php
analytics/classes/local/analyser/sitewide.php
analytics/classes/manager.php
analytics/classes/model.php
analytics/tests/manager_test.php
analytics/tests/prediction_test.php
analytics/upgrade.txt
blocks/calendar_month/tests/behat/block_calendar_month.feature
blocks/calendar_month/tests/behat/block_calendar_month_course.feature
blocks/calendar_month/tests/behat/block_calendar_month_dashboard.feature
blocks/calendar_month/tests/behat/block_calendar_month_frontpage.feature
blocks/calendar_upcoming/tests/behat/block_calendar_upcoming_course.feature
blocks/calendar_upcoming/tests/behat/block_calendar_upcoming_dashboard.feature
blocks/calendar_upcoming/tests/behat/block_calendar_upcoming_frontpage.feature
blocks/timeline/templates/event-list-content.mustache
calendar/classes/external/event_icon_exporter.php
calendar/classes/local/event/forms/eventtype.php
calendar/classes/local/event/forms/managesubscriptions.php
calendar/export_execute.php
calendar/externallib.php
calendar/lib.php
calendar/tests/behat/category_events.feature
calendar/tests/container_test.php
calendar/tests/event_factory_test.php
calendar/tests/event_mapper_test.php
calendar/tests/helpers.php
calendar/tests/repeat_event_collection_test.php
config-dist.php
course/classes/analytics/target/course_enrolments.php
course/classes/category.php
course/classes/management_renderer.php
course/externallib.php
course/lib.php
course/pending.php
course/request.php
course/request_form.php
course/tests/behat/course_request.feature [new file with mode: 0644]
course/tests/courselib_test.php
course/tests/courserequest_test.php
course/tests/targets_test.php
lang/en/admin.php
lang/en/analytics.php
lang/en/calendar.php
lang/en/deprecated.txt
lang/en/question.php
lib/classes/analytics/analyser/users.php
lib/classes/output/icon_system_fontawesome.php
lib/classes/useragent.php
lib/db/access.php
lib/db/install.xml
lib/db/upgrade.php
lib/form/dateselector.php
lib/form/datetimeselector.php
lib/formslib.php
lib/outputcomponents.php
lib/testing/generator/data_generator.php
lib/tests/analysers_test.php
lib/tests/behat/behat_general.php
lib/tests/core_media_player_native.php [new file with mode: 0644]
lib/tests/fixtures/testable_core_media_player.php [new file with mode: 0644]
lib/tests/fixtures/testable_core_media_player_native.php [new file with mode: 0644]
lib/tests/medialib_test.php
login/change_password.php
login/change_password_form.php
login/forgot_password_form.php
login/lib.php
login/set_password_form.php
login/signup.php
login/signup_form.php
media/player/html5audio/tests/player_test.php
media/player/html5video/tests/player_test.php
message/amd/build/message_drawer_view_overview_section.min.js
message/amd/build/message_drawer_view_overview_section.min.js.map
message/amd/src/message_drawer_view_overview_section.js
mod/assign/feedback/editpdf/classes/pdf.php
mod/assign/feedback/editpdf/fpdi/FpdfTpl.php [new file with mode: 0644]
mod/assign/feedback/editpdf/fpdi/FpdfTplTrait.php [new file with mode: 0644]
mod/assign/feedback/editpdf/fpdi/Fpdi.php [new file with mode: 0644]
mod/assign/feedback/editpdf/fpdi/FpdiException.php [new file with mode: 0644]
mod/assign/feedback/editpdf/fpdi/FpdiTrait.php [new file with mode: 0644]
mod/assign/feedback/editpdf/fpdi/LICENSE [deleted file]
mod/assign/feedback/editpdf/fpdi/PdfParser/CrossReference/AbstractReader.php [new file with mode: 0644]
mod/assign/feedback/editpdf/fpdi/PdfParser/CrossReference/CrossReference.php [new file with mode: 0644]
mod/assign/feedback/editpdf/fpdi/PdfParser/CrossReference/CrossReferenceException.php [new file with mode: 0644]
mod/assign/feedback/editpdf/fpdi/PdfParser/CrossReference/FixedReader.php [new file with mode: 0644]
mod/assign/feedback/editpdf/fpdi/PdfParser/CrossReference/LineReader.php [new file with mode: 0644]
mod/assign/feedback/editpdf/fpdi/PdfParser/CrossReference/ReaderInterface.php [new file with mode: 0644]
mod/assign/feedback/editpdf/fpdi/PdfParser/Filter/Ascii85.php [new file with mode: 0644]
mod/assign/feedback/editpdf/fpdi/PdfParser/Filter/Ascii85Exception.php [new file with mode: 0644]
mod/assign/feedback/editpdf/fpdi/PdfParser/Filter/AsciiHex.php [new file with mode: 0644]
mod/assign/feedback/editpdf/fpdi/PdfParser/Filter/FilterException.php [new file with mode: 0644]
mod/assign/feedback/editpdf/fpdi/PdfParser/Filter/FilterInterface.php [new file with mode: 0644]
mod/assign/feedback/editpdf/fpdi/PdfParser/Filter/Flate.php [new file with mode: 0644]
mod/assign/feedback/editpdf/fpdi/PdfParser/Filter/FlateException.php [new file with mode: 0644]
mod/assign/feedback/editpdf/fpdi/PdfParser/Filter/Lzw.php [new file with mode: 0644]
mod/assign/feedback/editpdf/fpdi/PdfParser/Filter/LzwException.php [new file with mode: 0644]
mod/assign/feedback/editpdf/fpdi/PdfParser/PdfParser.php [new file with mode: 0644]
mod/assign/feedback/editpdf/fpdi/PdfParser/PdfParserException.php [new file with mode: 0644]
mod/assign/feedback/editpdf/fpdi/PdfParser/StreamReader.php [new file with mode: 0644]
mod/assign/feedback/editpdf/fpdi/PdfParser/Tokenizer.php [new file with mode: 0644]
mod/assign/feedback/editpdf/fpdi/PdfParser/Type/PdfArray.php [new file with mode: 0644]
mod/assign/feedback/editpdf/fpdi/PdfParser/Type/PdfBoolean.php [new file with mode: 0644]
mod/assign/feedback/editpdf/fpdi/PdfParser/Type/PdfDictionary.php [new file with mode: 0644]
mod/assign/feedback/editpdf/fpdi/PdfParser/Type/PdfHexString.php [new file with mode: 0644]
mod/assign/feedback/editpdf/fpdi/PdfParser/Type/PdfIndirectObject.php [new file with mode: 0644]
mod/assign/feedback/editpdf/fpdi/PdfParser/Type/PdfIndirectObjectReference.php [new file with mode: 0644]
mod/assign/feedback/editpdf/fpdi/PdfParser/Type/PdfName.php [new file with mode: 0644]
mod/assign/feedback/editpdf/fpdi/PdfParser/Type/PdfNull.php [new file with mode: 0644]
mod/assign/feedback/editpdf/fpdi/PdfParser/Type/PdfNumeric.php [new file with mode: 0644]
mod/assign/feedback/editpdf/fpdi/PdfParser/Type/PdfStream.php [new file with mode: 0644]
mod/assign/feedback/editpdf/fpdi/PdfParser/Type/PdfString.php [new file with mode: 0644]
mod/assign/feedback/editpdf/fpdi/PdfParser/Type/PdfToken.php [new file with mode: 0644]
mod/assign/feedback/editpdf/fpdi/PdfParser/Type/PdfType.php [new file with mode: 0644]
mod/assign/feedback/editpdf/fpdi/PdfParser/Type/PdfTypeException.php [new file with mode: 0644]
mod/assign/feedback/editpdf/fpdi/PdfReader/DataStructure/Rectangle.php [new file with mode: 0644]
mod/assign/feedback/editpdf/fpdi/PdfReader/Page.php [new file with mode: 0644]
mod/assign/feedback/editpdf/fpdi/PdfReader/PageBoundaries.php [new file with mode: 0644]
mod/assign/feedback/editpdf/fpdi/PdfReader/PdfReader.php [new file with mode: 0644]
mod/assign/feedback/editpdf/fpdi/PdfReader/PdfReaderException.php [new file with mode: 0644]
mod/assign/feedback/editpdf/fpdi/Tcpdf/Fpdi.php [new file with mode: 0644]
mod/assign/feedback/editpdf/fpdi/TcpdfFpdi.php [new file with mode: 0644]
mod/assign/feedback/editpdf/fpdi/Tfpdf/FpdfTpl.php [new file with mode: 0644]
mod/assign/feedback/editpdf/fpdi/Tfpdf/Fpdi.php [new file with mode: 0644]
mod/assign/feedback/editpdf/fpdi/autoload.php [new file with mode: 0644]
mod/assign/feedback/editpdf/fpdi/filters/FilterASCII85.php [deleted file]
mod/assign/feedback/editpdf/fpdi/filters/FilterASCIIHexDecode.php [deleted file]
mod/assign/feedback/editpdf/fpdi/filters/FilterLZW.php [deleted file]
mod/assign/feedback/editpdf/fpdi/fpdf_tpl.php [deleted file]
mod/assign/feedback/editpdf/fpdi/fpdi.php [deleted file]
mod/assign/feedback/editpdf/fpdi/fpdi_bridge.php [deleted file]
mod/assign/feedback/editpdf/fpdi/fpdi_pdf_parser.php [deleted file]
mod/assign/feedback/editpdf/fpdi/pdf_context.php [deleted file]
mod/assign/feedback/editpdf/fpdi/pdf_parser.php [deleted file]
mod/assign/feedback/editpdf/fpdi/readme_moodle.txt
mod/assign/feedback/editpdf/thirdpartylibs.xml
mod/assign/locallib.php
mod/data/import.php
mod/data/lib.php
mod/data/tests/fixtures/test_data_import.csv [new file with mode: 0644]
mod/data/tests/fixtures/test_data_import_with_field_username.csv [new file with mode: 0644]
mod/data/tests/fixtures/test_data_import_with_userdata.csv [new file with mode: 0644]
mod/data/tests/import_test.php [new file with mode: 0644]
mod/feedback/classes/completion.php
mod/feedback/lib.php
mod/forum/backup/moodle2/restore_forum_stepslib.php
mod/forum/classes/form/export_form.php
mod/forum/classes/local/data_mappers/legacy/post.php
mod/forum/classes/local/entities/post.php
mod/forum/classes/local/exporters/post.php
mod/forum/classes/local/factories/entity.php
mod/forum/classes/local/vaults/post.php
mod/forum/classes/task/refresh_forum_post_counts.php [new file with mode: 0644]
mod/forum/db/install.xml
mod/forum/db/upgrade.php
mod/forum/export.php
mod/forum/lang/en/forum.php
mod/forum/lib.php
mod/forum/report/summary/classes/output/filters.php
mod/forum/report/summary/classes/summary_table.php
mod/forum/report/summary/index.php
mod/forum/report/summary/lang/en/forumreport_summary.php
mod/forum/report/summary/tests/behat/private_replies.feature [new file with mode: 0644]
mod/forum/tests/entities_discussion_summary_test.php
mod/forum/tests/entities_discussion_test.php
mod/forum/tests/entities_post_read_receipt_collection_test.php
mod/forum/tests/entities_post_test.php
mod/forum/tests/externallib_test.php
mod/forum/tests/generator/lib.php
mod/forum/tests/vaults_post_test.php
mod/forum/upgrade.txt
mod/forum/version.php
mod/quiz/classes/output/edit_renderer.php
mod/quiz/report/attemptsreport.php
mod/quiz/report/attemptsreport_table.php
mod/quiz/report/overview/report.php
mod/quiz/report/overview/tests/behat/basic.feature
mod/quiz/report/overview/tests/report_test.php
mod/quiz/report/responses/report.php
mod/quiz/report/upgrade.txt
mod/quiz/styles.css
pix/i/courseevent.png
pix/i/courseevent.svg
question/classes/bank/action_column_base.php
question/classes/bank/checkbox_column.php
question/classes/bank/column_base.php
question/classes/bank/copy_action_column.php
question/classes/bank/creator_name_column.php
question/classes/bank/delete_action_column.php
question/classes/bank/edit_action_column.php
question/classes/bank/edit_menu_column.php [new file with mode: 0644]
question/classes/bank/export_xml_action_column.php [new file with mode: 0644]
question/classes/bank/menu_action_column_base.php [new file with mode: 0644]
question/classes/bank/menuable_action.php [new file with mode: 0644]
question/classes/bank/modifier_name_column.php
question/classes/bank/preview_action_column.php
question/classes/bank/question_name_column.php
question/classes/bank/question_text_row.php
question/classes/bank/question_type_column.php
question/classes/bank/row_base.php
question/classes/bank/tags_action_column.php
question/classes/bank/view.php
question/tests/behat/behat_question.php
question/upgrade.txt
theme/boost/scss/moodle/question.scss
theme/boost/style/moodle.css
theme/classic/style/moodle.css
version.php

index 2525f22..5989158 100644 (file)
@@ -163,9 +163,15 @@ if ($hassiteconfig or has_any_capability($capabilities, $systemcontext)) {
 
     // "courserequests" settingpage.
     $temp = new admin_settingpage('courserequest', new lang_string('courserequest'));
-    $temp->add(new admin_setting_configcheckbox('enablecourserequests', new lang_string('enablecourserequests', 'admin'), new lang_string('configenablecourserequests', 'admin'), 0));
-    $temp->add(new admin_settings_coursecat_select('defaultrequestcategory', new lang_string('defaultrequestcategory', 'admin'), new lang_string('configdefaultrequestcategory', 'admin'), 1));
-    $temp->add(new admin_setting_configcheckbox('requestcategoryselection', new lang_string('requestcategoryselection', 'admin'), new lang_string('configrequestcategoryselection', 'admin'), 0));
+    $temp->add(new admin_setting_configcheckbox('enablecourserequests',
+        new lang_string('enablecourserequests', 'admin'),
+        new lang_string('configenablecourserequests', 'admin'), 1));
+    $temp->add(new admin_settings_coursecat_select('defaultrequestcategory',
+        new lang_string('defaultrequestcategory', 'admin'),
+        new lang_string('configdefaultrequestcategory', 'admin'), 1));
+    $temp->add(new admin_setting_configcheckbox('lockrequestcategory',
+        new lang_string('lockrequestcategory', 'admin'),
+        new lang_string('configlockrequestcategory', 'admin'), 0));
     $temp->add(new admin_setting_users_with_capability('courserequestnotify', new lang_string('courserequestnotify', 'admin'), new lang_string('configcourserequestnotify2', 'admin'), array(), 'moodle/site:approvecourse'));
     $ADMIN->add('courses', $temp);
 
diff --git a/admin/tool/analytics/amd/build/potential-contexts.min.js b/admin/tool/analytics/amd/build/potential-contexts.min.js
new file mode 100644 (file)
index 0000000..c93ee47
Binary files /dev/null and b/admin/tool/analytics/amd/build/potential-contexts.min.js differ
diff --git a/admin/tool/analytics/amd/build/potential-contexts.min.js.map b/admin/tool/analytics/amd/build/potential-contexts.min.js.map
new file mode 100644 (file)
index 0000000..03b6fb5
Binary files /dev/null and b/admin/tool/analytics/amd/build/potential-contexts.min.js.map differ
diff --git a/admin/tool/analytics/amd/src/potential-contexts.js b/admin/tool/analytics/amd/src/potential-contexts.js
new file mode 100644 (file)
index 0000000..eb35367
--- /dev/null
@@ -0,0 +1,63 @@
+// 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/>.
+
+/**
+ * Potential contexts selector module.
+ *
+ * @module     tool_analytics/potential-contexts
+ * @class      potential-contexts
+ * @package    tool_analytics
+ * @copyright  2019 David Monllao
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+define(['jquery', 'core/ajax'], function($, Ajax) {
+
+    return /** @alias module:tool_analytics/potential-contexts */ {
+
+        processResults: function(selector, results) {
+            var contexts = [];
+            if ($.isArray(results)) {
+                $.each(results, function(index, context) {
+                    contexts.push({
+                        value: context.id,
+                        label: context.name
+                    });
+                });
+                return contexts;
+
+            } else {
+                return results;
+            }
+        },
+
+        transport: function(selector, query, success, failure) {
+            var promise;
+
+            let modelid = $(selector).attr('modelid') || null;
+            promise = Ajax.call([{
+                methodname: 'tool_analytics_potential_contexts',
+                args: {
+                    query: query,
+                    modelid: modelid
+                }
+            }]);
+
+            promise[0].then(success).fail(failure);
+        }
+
+    };
+
+});
diff --git a/admin/tool/analytics/classes/external.php b/admin/tool/analytics/classes/external.php
new file mode 100644 (file)
index 0000000..515c08a
--- /dev/null
@@ -0,0 +1,113 @@
+<?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/>.
+
+/**
+ * This is the external API for this component.
+ *
+ * @package    tool_analytics
+ * @copyright  2019 David Monllao {@link http://www.davidmonllao.com}
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace tool_analytics;
+
+defined('MOODLE_INTERNAL') || die();
+
+require_once("$CFG->libdir/externallib.php");
+
+use external_api;
+use external_function_parameters;
+use external_value;
+use external_single_structure;
+use external_multiple_structure;
+
+/**
+ * This is the external API for this component.
+ *
+ * @copyright  2019 David Monllao {@link http://www.davidmonllao.com}
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class external extends external_api {
+
+    const MAX_CONTEXTS_RETURNED = 100;
+
+    /**
+     * potential_contexts parameters.
+     *
+     * @since  Moodle 3.8
+     * @return external_function_parameters
+     */
+    public static function potential_contexts_parameters() {
+        return new external_function_parameters(
+            array(
+                'query' => new external_value(PARAM_NOTAGS, 'The model id', VALUE_DEFAULT),
+                'modelid' => new external_value(PARAM_INT, 'The model id', VALUE_DEFAULT)
+            )
+        );
+    }
+
+    /**
+     * Return the contexts that match the provided query.
+     *
+     * @since  Moodle 3.8
+     * @param  string|null $query
+     * @param  int|null $modelid
+     * @return array an array of contexts
+     */
+    public static function potential_contexts(?string $query = null, ?int $modelid = null) {
+
+        $params = self::validate_parameters(self::potential_contexts_parameters(), ['modelid' => $modelid, 'query' => $query]);
+
+        \core_analytics\manager::check_can_manage_models();
+
+        if ($params['modelid']) {
+            $model = new \core_analytics\model($params['modelid']);
+            $contexts = ($model->get_analyser(['notimesplitting' => true]))::potential_context_restrictions($params['query']);
+        } else {
+            $contexts = \core_analytics\manager::get_potential_context_restrictions(null, $params['query']);
+        }
+
+        $contextoptions = [];
+        $i = 0;
+        foreach ($contexts as $contextid => $contextname) {
+
+            if ($i === self::MAX_CONTEXTS_RETURNED) {
+                // Limited to MAX_CONTEXTS_RETURNED items.
+                break;
+            }
+
+            $contextoptions[] = ['id' => $contextid, 'name' => $contextname];
+            $i++;
+        }
+
+        return $contextoptions;
+    }
+
+    /**
+     * potential_contexts return
+     *
+     * @since  Moodle 3.8
+     * @return external_description
+     */
+    public static function potential_contexts_returns() {
+        return new external_multiple_structure(
+            new external_single_structure([
+                'id'    => new external_value(PARAM_INT, 'ID of the context'),
+                'name'  => new external_value(PARAM_NOTAGS, 'The context name')
+            ])
+        );
+    }
+}
index c897b5c..b9515cd 100644 (file)
@@ -105,6 +105,28 @@ class edit_model extends \moodleform {
         $mform->addElement('select', 'timesplitting', get_string('timesplittingmethod', 'analytics'), $timesplittings);
         $mform->addHelpButton('timesplitting', 'timesplittingmethod', 'analytics');
 
+        // Contexts restriction.
+        if (!empty($this->_customdata['supportscontexts'])) {
+
+            $options = [
+                'ajax' => 'tool_analytics/potential-contexts',
+                'multiple' => true,
+                'noselectionstring' => get_string('all')
+            ];
+
+            if (!empty($this->_customdata['id'])) {
+                $options['modelid'] = $this->_customdata['id'];
+                $contexts = $this->load_current_contexts();
+            } else {
+                // No need to preload any selected contexts.
+                $contexts = [];
+            }
+
+            $mform->addElement('autocomplete', 'contexts', get_string('contexts', 'tool_analytics'), $contexts, $options);
+            $mform->setType('contexts', PARAM_INT);
+            $mform->addHelpButton('contexts', 'contexts', 'tool_analytics');
+        }
+
         // Predictions processor.
         if (!$this->_customdata['staticmodel']) {
             $defaultprocessor = \core_analytics\manager::get_predictions_processor_name(
@@ -146,20 +168,37 @@ class edit_model extends \moodleform {
     public function validation($data, $files) {
         $errors = parent::validation($data, $files);
 
+        $targetclass = \tool_analytics\output\helper::option_to_class($data['target']);
+        $target = \core_analytics\manager::get_target($targetclass);
+
         if (!empty($data['timesplitting'])) {
             $timesplittingclass = \tool_analytics\output\helper::option_to_class($data['timesplitting']);
             if (\core_analytics\manager::is_valid($timesplittingclass, '\core_analytics\local\time_splitting\base') === false) {
                 $errors['timesplitting'] = get_string('errorinvalidtimesplitting', 'analytics');
             }
 
-            $targetclass = \tool_analytics\output\helper::option_to_class($data['target']);
             $timesplitting = \core_analytics\manager::get_time_splitting($timesplittingclass);
-            $target = \core_analytics\manager::get_target($targetclass);
             if (!$target->can_use_timesplitting($timesplitting)) {
                 $errors['timesplitting'] = get_string('invalidtimesplitting', 'tool_analytics');
             }
         }
 
+        if (!empty($data['contexts'])) {
+
+            $analyserclass = $target->get_analyser_class();
+            if (!$potentialcontexts = $analyserclass::potential_context_restrictions()) {
+                $errors['contexts'] = get_string('errornocontextrestrictions', 'analytics');
+            } else {
+
+                // Flip the contexts array so we can just diff by key.
+                $selectedcontexts = array_flip($data['contexts']);
+                $invalidcontexts = array_diff_key($selectedcontexts, $potentialcontexts);
+                if (!empty($invalidcontexts)) {
+                    $errors['contexts'] = get_string('errorinvalidcontexts', 'analytics');
+                }
+            }
+        }
+
         if (!$this->_customdata['staticmodel']) {
             if (empty($data['indicators'])) {
                 $errors['indicators'] = get_string('errornoindicators', 'analytics');
@@ -179,4 +218,18 @@ class edit_model extends \moodleform {
 
         return $errors;
     }
+
+    /**
+     * Load the currently selected context options.
+     *
+     * @return array
+     */
+    protected function load_current_contexts() {
+        $contexts = [];
+        foreach ($this->_customdata['contexts'] as $context) {
+            $contexts[$context->id] = $context->get_context_name(true, true);
+        }
+
+        return $contexts;
+    }
 }
index fd205f1..2011754 100644 (file)
@@ -76,7 +76,8 @@ class invalid_analysables implements \renderable, \templatable {
 
         $offset = $this->page * $this->perpage;
 
-        $analysables = $this->model->get_analyser(['notimesplitting' => true])->get_analysables_iterator();
+        $contexts = $this->model->get_contexts();
+        $analysables = $this->model->get_analyser(['notimesplitting' => true])->get_analysables_iterator(null, $contexts);
 
         $skipped = 0;
         $enoughresults = false;
index 3094ffa..7d07b6f 100644 (file)
@@ -34,7 +34,6 @@ Options:
 --list                 List models
 --non-interactive      Not interactive questions
 --analysisinterval     Restrict the evaluation to 1 single analysis interval (Optional)
---filter               Analyser dependant. e.g. A courseid would evaluate the model using a single course (Optional)
 --mode                 'configuration' or 'trainedmodel'. You can only use mode=trainedmodel when the trained" .
     " model was imported" . "
 --reuse-prev-analysed  Reuse recently analysed courses instead of analysing the whole site. Set it to false while" .
@@ -42,7 +41,7 @@ Options:
 -h, --help             Print out this help
 
 Example:
-\$ php admin/tool/analytics/cli/evaluate_model.php --modelid=1 --analysisinterval='\\core\\analytics\\time_splitting\\quarters' --filter=123,321
+\$ php admin/tool/analytics/cli/evaluate_model.php --modelid=1 --analysisinterval='\\core\\analytics\\time_splitting\\quarters'
 ";
 
 // Now get cli options.
@@ -55,7 +54,6 @@ list($options, $unrecognized) = cli_get_params(
         'mode'                  => 'configuration',
         'reuse-prev-analysed'   => true,
         'non-interactive'       => false,
-        'filter'                => false
     ),
     array(
         'h' => 'help',
@@ -83,11 +81,6 @@ if ($options['modelid'] === false) {
     exit(0);
 }
 
-// Reformat them as an array.
-if ($options['filter'] !== false) {
-    $options['filter'] = explode(',', $options['filter']);
-}
-
 if ($options['mode'] !== 'configuration' && $options['mode'] !== 'trainedmodel') {
     cli_error('Error: The provided mode is not supported');
 }
@@ -110,7 +103,6 @@ if ($options['reuse-prev-analysed']) {
 $renderer = $PAGE->get_renderer('tool_analytics');
 
 $analyseroptions = array(
-    'filter' => $options['filter'],
     'timesplitting' => $options['analysisinterval'],
     'reuseprevanalysed' => $options['reuse-prev-analysed'],
     'mode' => $options['mode'],
index ad4fc83..678eec8 100644 (file)
@@ -45,6 +45,8 @@ $targets = array_filter(\core_analytics\manager::get_all_targets(), function($ta
     return (!$target->based_on_assumptions());
 });
 
+// Set 'supportscontexts' to true as at this stage we don't know if the contexts are supported by
+// the selected target.
 $customdata = array(
     'trainedmodel' => false,
     'staticmodel' => false,
@@ -52,6 +54,7 @@ $customdata = array(
     'indicators' => \core_analytics\manager::get_all_indicators(),
     'timesplittings' => \core_analytics\manager::get_all_time_splittings(),
     'predictionprocessors' => \core_analytics\manager::get_all_prediction_processors(),
+    'supportscontexts' => true,
 );
 $mform = new \tool_analytics\output\form\edit_model(null, $customdata);
 
@@ -86,8 +89,8 @@ if ($mform->is_cancelled()) {
         $indicators = array_diff_key($indicators, $invalidindicators);
     }
 
-    // Update the model with the valid list of indicators.
-    $model->update($data->enabled, $indicators, $timesplitting, $predictionsprocessor);
+    // Update the model with the rest of the data provided in the form.
+    $model->update($data->enabled, $indicators, $timesplitting, $predictionsprocessor, $data->contexts);
 
     $message = '';
     $messagetype = \core\output\notification::NOTIFY_SUCCESS;
diff --git a/admin/tool/analytics/db/services.php b/admin/tool/analytics/db/services.php
new file mode 100644 (file)
index 0000000..fefbfb8
--- /dev/null
@@ -0,0 +1,37 @@
+<?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/>.
+
+/**
+ * Tool analytics webservice definitions.
+ *
+ * @package    tool_analytics
+ * @copyright  2019 David Monllao {@link http://www.davidmonllao.com}
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+$functions = array(
+
+    'tool_analytics_potential_contexts' => array(
+        'classname'   => 'tool_analytics\external',
+        'methodname'  => 'potential_contexts',
+        'description' => 'Retrieve the list of potential contexts for a model.',
+        'type'        => 'read',
+        'ajax'          => true,
+        'services'    => array(MOODLE_OFFICIAL_MOBILE_SERVICE)
+    ),
+);
index ca3d1a2..acace09 100644 (file)
@@ -45,6 +45,8 @@ $string['component'] = 'Component';
 $string['componentcore'] = 'Core';
 $string['componentselect'] = 'Select all models provided by the component \'{$a}\'';
 $string['componentselectnone'] = 'Unselect all';
+$string['contexts'] = 'Contexts';
+$string['contexts_help'] = 'The model will be limited to this set of contexts. No context restrictions will be applied if no contexts are selected.';
 $string['createmodel'] = 'Create model';
 $string['currenttimesplitting'] = 'Current analysis interval';
 $string['delete'] = 'Delete';
index 4886ff5..3788f15 100644 (file)
@@ -122,6 +122,7 @@ switch ($action) {
 
         $invalidcurrenttimesplitting = $model->invalid_timesplitting_selected();
         $potentialtimesplittings = $model->get_potential_timesplittings();
+        $analyser = $model->get_analyser();
 
         $customdata = array(
             'id' => $model->get_id(),
@@ -132,7 +133,9 @@ switch ($action) {
             'targetname' => $model->get_target()->get_name(),
             'indicators' => $model->get_potential_indicators(),
             'timesplittings' => $potentialtimesplittings,
-            'predictionprocessors' => \core_analytics\manager::get_all_prediction_processors()
+            'predictionprocessors' => \core_analytics\manager::get_all_prediction_processors(),
+            'supportscontexts' => ($analyser)::context_restriction_support(),
+            'contexts' => $model->get_contexts(),
         );
         $mform = new \tool_analytics\output\form\edit_model(null, $customdata);
 
@@ -157,7 +160,7 @@ switch ($action) {
                 $predictionsprocessor = false;
             }
 
-            $model->update($data->enabled, $indicators, $timesplitting, $predictionsprocessor);
+            $model->update($data->enabled, $indicators, $timesplitting, $predictionsprocessor, $data->contexts);
             redirect($returnurl);
         }
 
@@ -168,6 +171,9 @@ switch ($action) {
         $callable = array('\tool_analytics\output\helper', 'class_to_option');
         $modelobj->indicators = array_map($callable, json_decode($modelobj->indicators));
         $modelobj->timesplitting = \tool_analytics\output\helper::class_to_option($modelobj->timesplitting);
+        if ($modelobj->contextids) {
+            $modelobj->contexts = array_map($callable, json_decode($modelobj->contextids));
+        }
         $modelobj->predictionsprocessor = \tool_analytics\output\helper::class_to_option($modelobj->predictionsprocessor);
         $mform->set_data($modelobj);
         $mform->display();
diff --git a/admin/tool/analytics/tests/external_test.php b/admin/tool/analytics/tests/external_test.php
new file mode 100644 (file)
index 0000000..ff26c64
--- /dev/null
@@ -0,0 +1,80 @@
+<?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/>.
+
+/**
+ * Tool analytics external functions tests.
+ *
+ * @package    tool_analytics
+ * @category   external
+ * @copyright  2019 David Monllaó {@link http://www.davidmonllao.com}
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ * @since      Moodle 3.8
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+global $CFG;
+
+require_once($CFG->dirroot . '/webservice/tests/helpers.php');
+require_once(__DIR__ . '/../../../../analytics/tests/fixtures/test_indicator_max.php');
+require_once(__DIR__ . '/../../../../analytics/tests/fixtures/test_target_course_level_shortname.php');
+
+/**
+ * Tool analytics external functions tests
+ *
+ * @package    tool_analytics
+ * @category   external
+ * @copyright  2019 David Monllaó {@link http://www.davidmonllao.com}
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ * @since      Moodle 3.8
+ */
+class tool_analytics_external_testcase extends externallib_advanced_testcase {
+
+    /**
+     * test_potential_contexts description
+     */
+    public function test_potential_contexts() {
+        $this->resetAfterTest();
+
+        $this->setAdminUser();
+
+        // Include the all context levels so the misc. category get included.
+        $this->assertCount(1, \tool_analytics\external::potential_contexts());
+
+        // The frontpage is not included.
+        $this->assertCount(0, \tool_analytics\external::potential_contexts('PHPUnit'));
+
+        $target = \core_analytics\manager::get_target('test_target_course_level_shortname');
+        $indicators = ['test_indicator_max' => \core_analytics\manager::get_indicator('test_indicator_max')];
+        $model = \core_analytics\model::create($target, $indicators);
+
+        $this->assertCount(1, \tool_analytics\external::potential_contexts(null, $model->get_id()));
+    }
+
+    /**
+     * test_potential_contexts description
+     *
+     * @expectedException required_capability_exception
+     */
+    public function test_potential_contexts_no_manager() {
+        $this->resetAfterTest();
+
+        $user = $this->getDataGenerator()->create_user();
+        $this->setUser($user);
+
+        $this->assertCount(2, \tool_analytics\external::potential_contexts());
+    }
+}
index 03c5187..c2e2b1c 100644 (file)
@@ -24,6 +24,6 @@
 
 defined('MOODLE_INTERNAL') || die();
 
-$plugin->version   = 2019052000; // The current plugin version (Date: YYYYMMDDXX).
+$plugin->version   = 2019052002; // The current plugin version (Date: YYYYMMDDXX).
 $plugin->requires  = 2019051100; // Requires this Moodle version.
 $plugin->component = 'tool_analytics'; // Full name of the plugin (used for diagnostics).
diff --git a/admin/tool/uploaduser/example.csv b/admin/tool/uploaduser/example.csv
new file mode 100644 (file)
index 0000000..355c3e1
--- /dev/null
@@ -0,0 +1,4 @@
+username,firstname,lastname,email
+student1,Student,One,s1@example.com
+student2,Student,Two,s2@example.com
+student3,Student,Three,s3@example.com
\ No newline at end of file
index e94ba5e..f0903ae 100644 (file)
@@ -33,6 +33,10 @@ $string['deleteerrors'] = 'Delete errors';
 $string['encoding'] = 'Encoding';
 $string['errormnetadd'] = 'Can not add remote users';
 $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['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.';
@@ -63,7 +67,9 @@ $string['uploadusers_help'] = 'Users may be uploaded (and optionally enrolled in
 * Each line of the file contains one record
 * Each record is a series of data separated by commas (or other delimiters)
 * The first record contains a list of fieldnames defining the format of the rest of the file
-* Required fieldnames are username, password, firstname, lastname, email';
+* Required fieldnames are username, password, firstname, lastname, email
+
+<a href="https://docs.moodle.org/en/Upload_users" target="_blank">More help</a>';
 $string['uploaduserspreview'] = 'Upload users preview';
 $string['uploadusersresult'] = 'Upload users results';
 $string['uploaduser:uploaduserpictures'] = 'Upload user pictures';
index 59b9131..552ea4c 100644 (file)
@@ -40,6 +40,11 @@ class admin_uploaduser_form1 extends moodleform {
 
         $mform->addElement('header', 'settingsheader', get_string('upload'));
 
+        $url = new moodle_url('example.csv');
+        $link = html_writer::link($url, 'example.csv');
+        $mform->addElement('static', 'examplecsv', get_string('examplecsv', 'tool_uploaduser'), $link);
+        $mform->addHelpButton('examplecsv', 'examplecsv', 'tool_uploaduser');
+
         $mform->addElement('filepicker', 'userfile', get_string('file'));
         $mform->addRule('userfile', null, 'required');
 
index ad39b48..f11bfae 100644 (file)
@@ -75,9 +75,10 @@ class analysis {
     /**
      * Runs the analysis.
      *
+     * @param \context[] $contexts Restrict the analysis to these contexts. No context restrictions if null.
      * @return null
      */
-    public function run() {
+    public function run(array $contexts = []) {
 
         $options = $this->analyser->get_options();
 
@@ -89,7 +90,7 @@ class analysis {
         } else {
             $action = 'prediction';
         }
-        $analysables = $this->analyser->get_analysables_iterator($action);
+        $analysables = $this->analyser->get_analysables_iterator($action, $contexts);
 
         $processedanalysables = $this->get_processed_analysables();
 
index 0065f15..99735bd 100644 (file)
@@ -131,9 +131,10 @@ abstract class base {
      * to ease to implementation of get_analysables_iterator: get_iterator_sql and order_sql.
      *
      * @param string|null $action 'prediction', 'training' or null if no specific action needed.
+     * @param \context[] $contexts Only analysables that depend on the provided contexts. All analysables in the system if empty.
      * @return \Iterator
      */
-    public function get_analysables_iterator(?string $action = null) {
+    public function get_analysables_iterator(?string $action = null, array $contexts = []) {
 
         debugging('Please overwrite get_analysables_iterator with your own implementation, we only keep this default
             implementation for backwards compatibility purposes with get_analysables(). note that $action param will
@@ -266,38 +267,42 @@ abstract class base {
     /**
      * Returns labelled data (training and evaluation).
      *
+     * @param \context[] $contexts Restrict the analysis to these contexts. No context restrictions if null.
      * @return \stored_file[]
      */
-    public function get_labelled_data() {
+    public function get_labelled_data(array $contexts = []) {
         // Delegates all processing to the analysis.
         $result = new \core_analytics\local\analysis\result_file($this->get_modelid(), true, $this->get_options());
         $analysis = new \core_analytics\analysis($this, true, $result);
-        $analysis->run();
+        $analysis->run($contexts);
         return $result->get();
     }
 
     /**
      * Returns unlabelled data (prediction).
      *
+     * @param \context[] $contexts Restrict the analysis to these contexts. No context restrictions if null.
      * @return \stored_file[]
      */
-    public function get_unlabelled_data() {
+    public function get_unlabelled_data(array $contexts = []) {
         // Delegates all processing to the analysis.
         $result = new \core_analytics\local\analysis\result_file($this->get_modelid(), false, $this->get_options());
         $analysis = new \core_analytics\analysis($this, false, $result);
-        $analysis->run();
+        $analysis->run($contexts);
         return $result->get();
     }
 
     /**
      * Returns indicator calculations as an array.
+     *
+     * @param \context[] $contexts Restrict the analysis to these contexts. No context restrictions if null.
      * @return array
      */
-    public function get_static_data() {
+    public function get_static_data(array $contexts = []) {
         // Delegates all processing to the analysis.
         $result = new \core_analytics\local\analysis\result_array($this->get_modelid(), false, $this->get_options());
         $analysis = new \core_analytics\analysis($this, false, $result);
-        $analysis->run();
+        $analysis->run($contexts);
         return $result->get();
     }
 
@@ -422,6 +427,34 @@ abstract class base {
         return false;
     }
 
+    /**
+     * Returns an array of context levels that can be used to restrict the contexts used during analysis.
+     *
+     * The contexts provided to self::get_analysables_iterator will match these contextlevels.
+     *
+     * @return array Array of context levels or an empty array if context restriction is not supported.
+     */
+    public static function context_restriction_support(): array {
+        return [];
+    }
+
+    /**
+     * Returns the possible contexts used by the analyser.
+     *
+     * This method uses separate logic for each context level because to iterate through
+     * the list of contexts calling get_context_name for each of them would be expensive
+     * in performance terms.
+     *
+     * This generic implementation returns all the contexts in the site for the provided context level.
+     * Overwrite it for specific restrictions in your analyser.
+     *
+     * @param  string|null $query Context name filter.
+     * @return int[]
+     */
+    public static function potential_context_restrictions(string $query = null) {
+        return \core_analytics\manager::get_potential_context_restrictions(static::context_restriction_support(), $query);
+    }
+
     /**
      * Get the sql of a default implementation of the iterator.
      *
@@ -431,9 +464,12 @@ abstract class base {
      * @param  int         $contextlevel The context level of the analysable
      * @param  string|null $action
      * @param  string|null $tablealias   The table alias
+     * @param  \context[]  $contexts     Only analysables that depend on the provided contexts. All analysables if empty.
      * @return array                     [0] => sql and [1] => params array
      */
-    protected function get_iterator_sql(string $tablename, int $contextlevel, ?string $action = null, ?string $tablealias = null) {
+    protected function get_iterator_sql(string $tablename, int $contextlevel, ?string $action = null, ?string $tablealias = null,
+            array $contexts = []) {
+        global $DB;
 
         if (!$tablealias) {
             $tablealias = 'analysable';
@@ -452,13 +488,30 @@ abstract class base {
             $params = $params + ['action' => $action];
         }
 
-        // Adding the 1 = 1 just to have the WHERE part so that all further conditions added by callers can be
-        // appended to $sql with and ' AND'.
         $sql = 'SELECT ' . $select . '
                   FROM {' . $tablename . '} ' . $tablealias . '
                   ' . $usedanalysablesjoin . '
-                  JOIN {context} ctx ON (ctx.contextlevel = :contextlevel AND ctx.instanceid = ' . $tablealias . '.id)
-                  WHERE 1 = 1';
+                  JOIN {context} ctx ON (ctx.contextlevel = :contextlevel AND ctx.instanceid = ' . $tablealias . '.id) ';
+
+        if (!$contexts) {
+            // Adding the 1 = 1 just to have the WHERE part so that all further conditions
+            // added by callers can be appended to $sql with and ' AND'.
+            $sql .= 'WHERE 1 = 1';
+        } else {
+
+            $contextsqls = [];
+            foreach ($contexts as $context) {
+                $paramkey1 = 'paramctxlike' . $context->id;
+                $paramkey2 = 'paramctxeq' . $context->id;
+                $contextsqls[] = $DB->sql_like('ctx.path', ':' . $paramkey1);
+                $contextsqls[] = 'ctx.path = :' . $paramkey2;
+
+                // This includes the context itself.
+                $params[$paramkey1] = $context->path . '/%';
+                $params[$paramkey2] = $context->path;
+            }
+            $sql .= 'WHERE (' . implode(' OR ', $contextsqls) . ')';
+        }
 
         return [$sql, $params];
     }
index c0d308b..59b6627 100644 (file)
@@ -39,24 +39,13 @@ abstract class by_course extends base {
      * Return the list of courses to analyse.
      *
      * @param string|null $action 'prediction', 'training' or null if no specific action needed.
+     * @param \context[] $contexts Only analysables that depend on the provided contexts. All analysables in the system if empty.
      * @return \Iterator
      */
-    public function get_analysables_iterator(?string $action = null) {
+    public function get_analysables_iterator(?string $action = null, array $contexts = []) {
         global $DB;
 
-        list($sql, $params) = $this->get_iterator_sql('course', CONTEXT_COURSE, $action, 'c');
-
-        // This will be updated to filter by context as part of MDL-64739.
-        if (!empty($this->options['filter'])) {
-            $courses = array();
-            foreach ($this->options['filter'] as $courseid) {
-                $courses[$courseid] = intval($courseid);
-            }
-
-            list($coursesql, $courseparams) = $DB->get_in_or_equal($courses, SQL_PARAMS_NAMED);
-            $sql .= " AND c.id $coursesql";
-            $params = $params + $courseparams;
-        }
+        list($sql, $params) = $this->get_iterator_sql('course', CONTEXT_COURSE, $action, 'c', $contexts);
 
         $ordersql = $this->order_sql('sortorder', 'ASC', 'c');
 
@@ -76,4 +65,13 @@ abstract class by_course extends base {
             return \core_analytics\course::instance($record, $context);
         });
     }
-}
\ No newline at end of file
+
+    /**
+     * Can be limited to course categories or specific courses.
+     *
+     * @return array
+     */
+    public static function context_restriction_support(): array {
+        return [CONTEXT_COURSE, CONTEXT_COURSECAT];
+    }
+}
index da6ea71..28a4db3 100644 (file)
@@ -39,9 +39,10 @@ abstract class sitewide extends base {
      * Return the list of analysables to analyse.
      *
      * @param string|null $action 'prediction', 'training' or null if no specific action needed.
+     * @param \context[] $contexts Ignored here.
      * @return \Iterator
      */
-    public function get_analysables_iterator(?string $action = null) {
+    public function get_analysables_iterator(?string $action = null, array $contexts = []) {
         // We can safely ignore $action as we have 1 single analysable element in this analyser.
         return new \ArrayIterator([new \core_analytics\site()]);
     }
index 60f1f45..131c782 100644 (file)
@@ -646,6 +646,8 @@ class manager {
             $usedanalysablesanalysableids = array_flip($usedanalysablesanalysableids);
 
             $analyser = $model->get_analyser(array('notimesplitting' => true));
+
+            // We do not honour the list of contexts in this model as it can contain stale records.
             $analysables = $analyser->get_analysables_iterator();
 
             $analysableids = [];
@@ -913,4 +915,79 @@ class manager {
 
         return [$target, $indicators];
     }
+
+    /**
+     * Return the context restrictions that can be applied to the provided context levels.
+     *
+     * @throws \coding_exception
+     * @param  array|null $contextlevels The list of context levels provided by the analyser. Null if all of them.
+     * @param  string|null $query
+     * @return array Associative array with contextid as key and the short version of the context name as value.
+     */
+    public static function get_potential_context_restrictions(?array $contextlevels = null, string $query = null) {
+        global $DB;
+
+        if (empty($contextlevels) && !is_null($contextlevels)) {
+            return false;
+        }
+
+        if (!is_null($contextlevels)) {
+            foreach ($contextlevels as $contextlevel) {
+                if ($contextlevel !== CONTEXT_COURSE && $contextlevel !== CONTEXT_COURSECAT) {
+                    throw new \coding_exception('Only CONTEXT_COURSE and CONTEXT_COURSECAT are supported at the moment.');
+                }
+            }
+        }
+
+        $contexts = [];
+
+        // We have a separate process for each context level for performance reasons (to iterate through mdl_context calling
+        // get_context_name() would be too slow).
+        $contextsystem = \context_system::instance();
+        if (is_null($contextlevels) || in_array(CONTEXT_COURSECAT, $contextlevels)) {
+
+            $sql = "SELECT cc.id, cc.name, ctx.id AS contextid
+                      FROM {course_categories} cc
+                      JOIN {context} ctx ON ctx.contextlevel = :ctxlevel AND ctx.instanceid = cc.id";
+            $params = ['ctxlevel' => CONTEXT_COURSECAT];
+
+            if ($query) {
+                $sql .= " WHERE " . $DB->sql_like('cc.name', ':query', false, false);
+                $params['query'] = '%' . $query . '%';
+            }
+
+            $coursecats = $DB->get_recordset_sql($sql, $params);
+            foreach ($coursecats as $record) {
+                $contexts[$record->contextid] = get_string('category') . ': ' .
+                    format_string($record->name, true, array('context' => $contextsystem));
+            }
+            $coursecats->close();
+        }
+
+        if (is_null($contextlevels) || in_array(CONTEXT_COURSE, $contextlevels)) {
+
+            $sql = "SELECT c.id, c.shortname, ctx.id AS contextid
+                      FROM {course} c
+                      JOIN {context} ctx ON ctx.contextlevel = :ctxlevel AND ctx.instanceid = c.id
+                      WHERE c.id != :siteid";
+            $params = ['ctxlevel' => CONTEXT_COURSE, 'siteid' => SITEID];
+
+            if ($query) {
+                $sql .= ' AND (' . $DB->sql_like('c.fullname', ':query1', false, false) . ' OR ' .
+                    $DB->sql_like('c.shortname', ':query2', false, false) . ')';
+                $params['query1'] = '%' . $query . '%';
+                $params['query2'] = '%' . $query . '%';
+            }
+
+            $courses = $DB->get_recordset_sql($sql, $params);
+            foreach ($courses as $record) {
+                $contexts[$record->contextid] = get_string('course') . ': ' .
+                    format_string($record->shortname, true, array('context' => $contextsystem));
+            }
+            $courses->close();
+        }
+
+        return $contexts;
+    }
+
 }
index 9a524be..cc92516 100644 (file)
@@ -120,6 +120,11 @@ class model {
      */
     protected $indicators = null;
 
+    /**
+     * @var \context[]
+     */
+    protected $contexts = null;
+
     /**
      * Unique Model id created from site info and last model modification.
      *
@@ -459,9 +464,11 @@ class model {
      * @param \core_analytics\local\indicator\base[]|false $indicators False to respect current indicators
      * @param string|false $timesplittingid False to respect current time splitting method
      * @param string|false $predictionsprocessor False to respect current predictors processor value
+     * @param int[]|false $contextids List of context ids for this model. False to respect the current list of contexts.
      * @return void
      */
-    public function update($enabled, $indicators = false, $timesplittingid = '', $predictionsprocessor = false) {
+    public function update($enabled, $indicators = false, $timesplittingid = '', $predictionsprocessor = false,
+            $contextids = false) {
         global $USER, $DB;
 
         \core_analytics\manager::check_can_manage_models();
@@ -486,6 +493,15 @@ class model {
             $predictionsprocessor = $this->model->predictionsprocessor;
         }
 
+        if ($contextids !== false) {
+            $contextsstr = json_encode($contextids);
+
+            // Reset the internal cache.
+            $this->contexts = null;
+        } else {
+            $contextsstr = $this->model->contextids;
+        }
+
         if ($this->model->timesplitting !== $timesplittingid ||
                 $this->model->indicators !== $indicatorsstr ||
                 $this->model->predictionsprocessor !== $predictionsprocessor) {
@@ -514,6 +530,7 @@ class model {
         $this->model->indicators = $indicatorsstr;
         $this->model->timesplitting = $timesplittingid;
         $this->model->predictionsprocessor = $predictionsprocessor;
+        $this->model->contextids = $contextsstr;
         $this->model->timemodified = $now;
         $this->model->usermodified = $USER->id;
 
@@ -603,7 +620,7 @@ class model {
         // Before get_labelled_data call so we get an early exception if it is not ready.
         $predictor = $this->get_predictions_processor();
 
-        $datasets = $this->get_analyser()->get_labelled_data();
+        $datasets = $this->get_analyser()->get_labelled_data($this->get_contexts());
 
         // No datasets generated.
         if (empty($datasets)) {
@@ -695,7 +712,7 @@ class model {
         // Before get_labelled_data call so we get an early exception if it is not ready.
         $predictor = $this->get_predictions_processor();
 
-        $datasets = $this->get_analyser()->get_labelled_data();
+        $datasets = $this->get_analyser()->get_labelled_data($this->get_contexts());
 
         // No training if no files have been provided.
         if (empty($datasets) || empty($datasets[$this->model->timesplitting])) {
@@ -766,7 +783,7 @@ class model {
             // Before get_unlabelled_data call so we get an early exception if it is not ready.
             $predictor = $this->get_predictions_processor();
 
-            $samplesdata = $this->get_analyser()->get_unlabelled_data();
+            $samplesdata = $this->get_analyser()->get_unlabelled_data($this->get_contexts());
 
             // Get the prediction samples file.
             if (empty($samplesdata) || empty($samplesdata[$this->model->timesplitting])) {
@@ -802,7 +819,7 @@ class model {
         } else {
             // Predictions based on assumptions.
 
-            $indicatorcalculations = $this->get_analyser()->get_static_data();
+            $indicatorcalculations = $this->get_analyser()->get_static_data($this->get_contexts());
             // Get the prediction samples file.
             if (empty($indicatorcalculations) || empty($indicatorcalculations[$this->model->timesplitting])) {
 
@@ -1956,6 +1973,30 @@ class model {
         return [$allsampleids, $allsamplesdata];
     }
 
+    /**
+     * Contexts where this model should be active.
+     *
+     * @return \context[] Empty array if there are no context restrictions.
+     */
+    public function get_contexts() {
+        if ($this->contexts !== null) {
+            return $this->contexts;
+        }
+
+        if (!$this->model->contextids) {
+            $this->contexts = [];
+            return $this->contexts;
+        }
+        $contextids = json_decode($this->model->contextids);
+
+        // We don't expect this list to be massive as contexts need to be selected manually using the edit model form.
+        $this->contexts = array_map(function($contextid) {
+            return \context::instance_by_id($contextid, IGNORE_MISSING);
+        }, $contextids);
+
+        return $this->contexts;
+    }
+
     /**
      * Purges the insights cache.
      */
index 02166a8..582c457 100644 (file)
@@ -487,4 +487,31 @@ class analytics_manager_testcase extends advanced_testcase {
         $this->assertNotEmpty($indicators);
         $this->assertContainsOnlyInstancesOf(\core_analytics\local\indicator\base::class, $indicators);
     }
+
+    /**
+     * test_get_potential_context_restrictions description
+     */
+    public function test_get_potential_context_restrictions() {
+        $this->resetAfterTest();
+
+        // No potential context restrictions.
+        $this->assertFalse(\core_analytics\manager::get_potential_context_restrictions([]));
+
+        // Include the all context levels so the misc. category get included.
+        $this->assertCount(1, \core_analytics\manager::get_potential_context_restrictions());
+
+        $this->getDataGenerator()->create_course();
+        $this->getDataGenerator()->create_category();
+        $this->assertCount(3, \core_analytics\manager::get_potential_context_restrictions());
+        $this->assertCount(3, \core_analytics\manager::get_potential_context_restrictions([CONTEXT_COURSE, CONTEXT_COURSECAT]));
+
+        $this->assertCount(1, \core_analytics\manager::get_potential_context_restrictions([CONTEXT_COURSE]));
+        $this->assertCount(2, \core_analytics\manager::get_potential_context_restrictions([CONTEXT_COURSECAT]));
+
+        $this->assertCount(1, \core_analytics\manager::get_potential_context_restrictions([CONTEXT_COURSECAT], 'Course category'));
+        $this->assertCount(1, \core_analytics\manager::get_potential_context_restrictions([CONTEXT_COURSECAT], 'Course category 1'));
+        $this->assertCount(1, \core_analytics\manager::get_potential_context_restrictions([CONTEXT_COURSECAT], 'Miscellaneous'));
+        $this->assertCount(1, \core_analytics\manager::get_potential_context_restrictions([CONTEXT_COURSE], 'Test course 1'));
+        $this->assertCount(1, \core_analytics\manager::get_potential_context_restrictions([CONTEXT_COURSE], 'Test course'));
+    }
 }
index 2b437f6..1fd9ac2 100644 (file)
@@ -126,6 +126,57 @@ class core_analytics_prediction_testcase extends advanced_testcase {
             array('modelid' => $model->get_id())));
     }
 
+    /**
+     * test_model_contexts
+     */
+    public function test_model_contexts() {
+        global $DB;
+
+        $this->resetAfterTest(true);
+        $this->setAdminuser();
+
+        $misc = $DB->get_record('course_categories', ['name' => 'Miscellaneous']);
+        $miscctx = \context_coursecat::instance($misc->id);
+
+        $category = $this->getDataGenerator()->create_category();
+        $categoryctx = \context_coursecat::instance($category->id);
+
+        // One course per category.
+        $courseparams = array('shortname' => 'aaaaaa', 'fullname' => 'aaaaaa', 'visible' => 0,
+            'category' => $category->id);
+        $course1 = $this->getDataGenerator()->create_course($courseparams);
+        $course1ctx = \context_course::instance($course1->id);
+        $courseparams = array('shortname' => 'bbbbbb', 'fullname' => 'bbbbbb', 'visible' => 0,
+            'category' => $misc->id);
+        $course2 = $this->getDataGenerator()->create_course($courseparams);
+
+        $model = $this->add_perfect_model('test_static_target_shortname');
+
+        // Just 1 category.
+        $model->update(true, false, '\core\analytics\time_splitting\no_splitting', false, [$categoryctx->id]);
+        $this->assertCount(1, $model->predict()->predictions);
+
+        // Now with 2 categories.
+        $model->update(true, false, false, false, [$categoryctx->id, $miscctx->id]);
+
+        // The courses in the new category are processed.
+        $this->assertCount(1, $model->predict()->predictions);
+
+        // Clear the predictions generated by the model and predict() again.
+        $model->clear();
+        $this->assertCount(2, $model->predict()->predictions);
+
+        // Course context restriction.
+        $model->update(true, false, '\core\analytics\time_splitting\no_splitting', false, [$course1ctx->id]);
+
+        // Nothing new as the course was already analysed.
+        $result = $model->predict();
+        $this->assertTrue(empty($result->predictions));
+
+        $model->clear();
+        $this->assertCount(1, $model->predict()->predictions);
+    }
+
     /**
      * test_ml_training_and_prediction
      *
index 38395c3..4773d4a 100644 (file)
@@ -30,6 +30,10 @@ information provided here is intended especially for developers.
     * Predictions flagged as "Not useful" in models whose targets use analysers that provide multiple samples
       per analysable (e.g. students at risk or no teaching) have been updated to "Incorrectly flagged".
 * \core_analytics\predictor::delete_output_dir has a new 2nd parameter, $uniquemodelid.
+* Analyser's get_analysables_iterator and get_iterator_sql have a new $contexts parameter to limit the returned analysables to
+  the ones that depend on the provided contexts.
+* Analysers can implement a context_restriction_support() method to restrict models to a subset of the
+  contents in the site. Only CONTEXT_COURSE and CONTEXT_COURSECAT are supported.
 
 === 3.7 ===
 
index f8f1597..b921b62 100644 (file)
@@ -26,7 +26,7 @@ Feature: Enable the calendar block in a course and test it's functionality
     Then "Calendar" "block" should exist
 
   @javascript
-  Scenario: View a global event in the calendar block
+  Scenario: View a site event in the calendar block
     Given I log in as "admin"
     And I create a calendar event with form data:
       | id_eventtype | Site |
@@ -52,7 +52,7 @@ Feature: Enable the calendar block in a course and test it's functionality
       | id_eventtype | Course |
       | id_name | Course Event |
     And I am on "Course 1" course homepage
-    And I follow "Hide global events"
+    And I follow "Hide site events"
     And I hover over today in the calendar
     Then I should not see "Site Event"
     And I should see "Course Event"
index 2360572..f0ed21f 100644 (file)
@@ -5,7 +5,7 @@ Feature: Enable the calendar block in a course
   I can add the calendar block to a course
 
   @javascript
-  Scenario: View a global event in the calendar block in a course
+  Scenario: View a site event in the calendar block in a course
     Given the following "users" exist:
       | username | firstname | lastname | email | idnumber |
       | teacher1 | Teacher | 1 | teacher1@example.com | T1 |
index aa74c43..e22b4e8 100644 (file)
@@ -5,7 +5,7 @@ Feature: View a site event on the dashboard
   I can view the event in the calendar
 
   @javascript
-  Scenario: View a global event in the calendar block on the dashboard
+  Scenario: View a site event in the calendar block on the dashboard
     Given the following "users" exist:
       | username | firstname | lastname | email | idnumber |
       | student1 | Student | 1 | student1@example.com | S1 |
index b7ef68e..e67c327 100644 (file)
@@ -5,7 +5,7 @@ Feature: Enable the calendar block on the site front page
   I can add the calendar block on the site front page
 
   @javascript
-  Scenario: View a global event in the calendar block on the front page
+  Scenario: View a site event in the calendar block on the front page
     Given the following "users" exist:
       | username | firstname | lastname | email | idnumber |
       | student1 | Student | 1 | student1@example.com | S1 |
index 9e092bf..1b4d9e0 100644 (file)
@@ -16,7 +16,7 @@ Feature: Enable the upcoming events block in a course
       | teacher1 | C1 | editingteacher |
 
   @javascript
-  Scenario: View a global event in the calendar block
+  Scenario: View a site event in the calendar block
     Given I log in as "admin"
     And I create a calendar event with form data:
       | id_eventtype | Site |
index 0b7c739..96bd216 100644 (file)
@@ -9,7 +9,7 @@ Feature: View a upcoming site event on the dashboard
       | student1 | Student | 1 | student1@example.com | S1 |
 
   @javascript
-  Scenario: View a global event in the upcoming events block on the dashboard
+  Scenario: View a site event in the upcoming events block on the dashboard
     Given I log in as "admin"
     And I create a calendar event with form data:
       | id_eventtype | Site |
index dd31e37..8336f52 100644 (file)
@@ -10,7 +10,7 @@ Feature: View a site event on the frontpage
       | teacher1 | Teacher | 1 | teacher1@example.com | T1 |
 
   @javascript
-  Scenario: View a global event in the upcoming events block on the frontpage
+  Scenario: View a site event in the upcoming events block on the frontpage
     Given I log in as "admin"
     And I create a calendar event with form data:
       | id_eventtype | Site |
index b8df729..f138e9c 100644 (file)
@@ -65,7 +65,7 @@
 }}
 <div class="border-bottom pb-2">
     {{#eventsbyday}}
-        <h5 class="h6 mt-3 mb-0 {{#past}}text-danger{{/past}}">{{#userdate}} {{dayTimestamp}}, {{#str}} strftimedayshort, core_langconfig {{/str}}  {{/userdate}}</h5>
+        <h5 class="h6 mt-3 mb-0 {{#past}}text-danger{{/past}}">{{#userdate}} {{dayTimestamp}}, {{#str}} strftimedaydate, core_langconfig {{/str}}  {{/userdate}}</h5>
         {{> block_timeline/event-list-items }}
     {{/eventsbyday}}
 </div>
\ No newline at end of file
index 79d30da..1949a9e 100644 (file)
@@ -55,7 +55,7 @@ class event_icon_exporter extends exporter {
         $user = $event->get_user();
         $userid = $user ? $user->get('id') : null;
         $isactivityevent = !empty($coursemodule);
-        $isglobalevent = ($course && $courseid == SITEID);
+        $issiteevent = ($course && $courseid == SITEID);
         $iscategoryevent = ($category && !empty($categoryid));
         $iscourseevent = ($course && !empty($courseid) && $courseid != SITEID && empty($groupid));
         $isgroupevent = ($group && !empty($groupid));
@@ -70,7 +70,7 @@ class event_icon_exporter extends exporter {
             } else {
                 $alttext = get_string('activityevent', 'calendar');
             }
-        } else if ($isglobalevent) {
+        } else if ($issiteevent) {
             $key = 'i/siteevent';
             $component = 'core';
             $alttext = get_string('typesite', 'calendar');
index d615a50..8475e29 100644 (file)
@@ -57,19 +57,19 @@ trait eventtype {
         $options = [];
 
         if (!empty($eventtypes['user'])) {
-            $options['user'] = get_string('user');
+            $options['user'] = get_string('user', 'calendar');
         }
         if (!empty($eventtypes['group'])) {
-            $options['group'] = get_string('group');
+            $options['group'] = get_string('group', 'calendar');
         }
         if (!empty($eventtypes['course'])) {
-            $options['course'] = get_string('course');
+            $options['course'] = get_string('course', 'calendar');
         }
         if (!empty($eventtypes['category'])) {
-            $options['category'] = get_string('category');
+            $options['category'] = get_string('category', 'calendar');
         }
         if (!empty($eventtypes['site'])) {
-            $options['site'] = get_string('site');
+            $options['site'] = get_string('site', 'calendar');
         }
 
         // If we only have one event type and it's 'user' event then don't bother
index 61ef73f..6063f40 100644 (file)
@@ -85,7 +85,7 @@ class managesubscriptions extends \moodleform {
         // Add the select elements for the available event types.
         $this->add_event_type_elements($mform, $eventtypes);
 
-        // Eventtype: 0 = user, 1 = global, anything else = course ID.
+        // Eventtype: 0 = user, 1 = site, anything else = course ID.
         $mform->addElement('submit', 'add', get_string('add'));
 
         // Add the javascript required to enhance this mform.
index 17b5501..a06f328 100644 (file)
@@ -76,7 +76,7 @@ if(!empty($what) && !empty($time)) {
         if ($what == 'all') {
             $users = $user->id;
             $courses[SITEID] = new stdClass;
-            $courses[SITEID]->shortname = get_string('globalevents', 'calendar');
+            $courses[SITEID]->shortname = get_string('siteevents', 'calendar');
             $paramcourses[SITEID] = $courses[SITEID];
             $paramcategory = true;
         } else if ($what == 'groups') {
index 4bc9388..eccd04e 100644 (file)
@@ -147,7 +147,7 @@ class core_calendar_external extends external_api {
                                              "Set to true to return current user's user events",
                                              VALUE_DEFAULT, true, NULL_ALLOWED),
                                     'siteevents' => new external_value(PARAM_BOOL,
-                                             "Set to true to return global events",
+                                             "Set to true to return site events",
                                              VALUE_DEFAULT, true, NULL_ALLOWED),
                                     'timestart' => new external_value(PARAM_INT,
                                              "Time from which events should be returned",
index a5f772e..da878b8 100644 (file)
@@ -70,10 +70,16 @@ define('CALENDAR_TF_24', '%H:%M');
 define('CALENDAR_TF_12', '%I:%M %p');
 
 /**
- * CALENDAR_EVENT_GLOBAL - Global calendar event types
+ * CALENDAR_EVENT_GLOBAL - Site calendar event types
+ * @deprecated since 3.8
  */
 define('CALENDAR_EVENT_GLOBAL', 1);
 
+/**
+ * CALENDAR_EVENT_SITE - Site calendar event types
+ */
+define('CALENDAR_EVENT_SITE', 1);
+
 /**
  * CALENDAR_EVENT_COURSE - Course calendar event types
  */
@@ -1551,8 +1557,8 @@ function calendar_add_event_metadata($event) {
         $event->cmid = $module->id;
     } else if ($event->courseid == SITEID) { // Site event.
         $event->icon = '<img src="' . $OUTPUT->image_url('i/siteevent') . '" alt="' .
-            get_string('globalevent', 'calendar') . '" class="icon" />';
-        $event->cssclass = 'calendar_event_global';
+            get_string('siteevent', 'calendar') . '" class="icon" />';
+        $event->cssclass = 'calendar_event_site';
     } else if ($event->courseid != 0 && $event->courseid != SITEID && $event->groupid == 0) { // Course event.
         $event->icon = '<img src="' . $OUTPUT->image_url('i/courseevent') . '" alt="' .
             get_string('courseevent', 'calendar') . '" class="icon" />';
@@ -1958,7 +1964,7 @@ function calendar_sub_month($month, $year) {
  * @param int $year the number of the year
  * @param array $eventsbyday event on specific day
  * @param array $durationbyday duration of the event in days
- * @param array $typesbyday event type (eg: global, course, user, or group)
+ * @param array $typesbyday event type (eg: site, course, user, or group)
  * @param array $courses list of courses
  * @return void
  */
@@ -1995,9 +2001,9 @@ function calendar_events_by_day($events, $month, $year, &$eventsbyday, &$duratio
 
             // Mark the day as having such an event.
             if ($event->courseid == SITEID && $event->groupid == 0) {
-                $typesbyday[$eventdaystart]['startglobal'] = true;
-                // Set event class for global event.
-                $events[$event->id]->class = 'calendar_event_global';
+                $typesbyday[$eventdaystart]['startsite'] = true;
+                // Set event class for site event.
+                $events[$event->id]->class = 'calendar_event_site';
             } else if ($event->courseid != 0 && $event->courseid != SITEID && $event->groupid == 0) {
                 $typesbyday[$eventdaystart]['startcourse'] = true;
                 // Set event class for course event.
@@ -2036,7 +2042,7 @@ function calendar_events_by_day($events, $month, $year, &$eventsbyday, &$duratio
         for ($i = $lowerbound + 1; $i <= $upperbound; ++$i) {
             $durationbyday[$i][] = $event->id;
             if ($event->courseid == SITEID && $event->groupid == 0) {
-                $typesbyday[$i]['durationglobal'] = true;
+                $typesbyday[$i]['durationsite'] = true;
             } else if ($event->courseid != 0 && $event->courseid != SITEID && $event->groupid == 0) {
                 $typesbyday[$i]['durationcourse'] = true;
             } else if ($event->groupid) {
@@ -2077,7 +2083,7 @@ function calendar_set_filters(array $courseeventsfrom, $ignorefilters = false, s
     if ($ignorefilters || calendar_show_event_type(CALENDAR_EVENT_COURSE, $user)) {
         $courses = array_keys($courseeventsfrom);
     }
-    if ($ignorefilters || calendar_show_event_type(CALENDAR_EVENT_GLOBAL, $user)) {
+    if ($ignorefilters || calendar_show_event_type(CALENDAR_EVENT_SITE, $user)) {
         $courses[] = SITEID;
     }
     $courses = array_unique($courses);
@@ -2472,7 +2478,7 @@ function calendar_format_event_time($event, $now, $linkparams = null, $usecommon
  * @return bool True if the tyep should be displayed false otherwise
  */
 function calendar_show_event_type($type, $user = null) {
-    $default = CALENDAR_EVENT_GLOBAL + CALENDAR_EVENT_COURSE + CALENDAR_EVENT_GROUP + CALENDAR_EVENT_USER;
+    $default = CALENDAR_EVENT_SITE + CALENDAR_EVENT_COURSE + CALENDAR_EVENT_GROUP + CALENDAR_EVENT_USER;
 
     if (get_user_preferences('calendar_persistflt', 0, $user) === 0) {
         global $SESSION;
@@ -2498,7 +2504,7 @@ function calendar_show_event_type($type, $user = null) {
  */
 function calendar_set_event_type_display($type, $display = null, $user = null) {
     $persist = get_user_preferences('calendar_persistflt', 0, $user);
-    $default = CALENDAR_EVENT_GLOBAL + CALENDAR_EVENT_COURSE + CALENDAR_EVENT_GROUP
+    $default = CALENDAR_EVENT_SITE + CALENDAR_EVENT_COURSE + CALENDAR_EVENT_GROUP
             + CALENDAR_EVENT_USER + CALENDAR_EVENT_COURSECAT;
     if ($persist === 0) {
         global $SESSION;
index 5382c8f..021126a 100644 (file)
@@ -48,7 +48,7 @@ Feature: Course Category Events
       | student2    | B2i       | student           |
     And the following "events" exist:
       | name        | eventtype |
-      | Site event  | global    |
+      | Site event  | site    |
     And the following "events" exist:
       | name        | eventtype | course |
       | CA1i event  | course    | A1i    |
index 011464b..bc72391 100644 (file)
@@ -532,7 +532,7 @@ class core_calendar_container_testcase extends advanced_testcase {
     protected function create_event($properties = []) {
         $record = new \stdClass();
         $record->name = 'event name';
-        $record->eventtype = 'global';
+        $record->eventtype = 'site';
         $record->timestart = time();
         $record->timeduration = 0;
         $record->timesort = 0;
index abf40ea..f1c8b44 100644 (file)
@@ -467,7 +467,7 @@ class core_calendar_event_factory_testcase extends advanced_testcase {
     protected function create_event($properties = []) {
         $record = new \stdClass();
         $record->name = 'event name';
-        $record->eventtype = 'global';
+        $record->eventtype = 'site';
         $record->timestart = time();
         $record->timeduration = 0;
         $record->timesort = 0;
index d59bccc..a9a39a5 100644 (file)
@@ -141,7 +141,7 @@ class core_calendar_event_mapper_testcase extends advanced_testcase {
     protected function create_event($properties = []) {
         $record = new \stdClass();
         $record->name = 'event name';
-        $record->eventtype = 'global';
+        $record->eventtype = 'site';
         $record->timestart = time();
         $record->timeduration = 0;
         $record->timesort = 0;
index 92f2e2e..9a875f8 100644 (file)
@@ -48,7 +48,7 @@ use core_calendar\local\event\factories\event_factory_interface;
 function create_event($properties) {
     $record = new \stdClass();
     $record->name = 'event name';
-    $record->eventtype = 'global';
+    $record->eventtype = 'site';
     $record->repeat = 0;
     $record->repeats = 0;
     $record->timestart = time();
index 7d335ea..8c963bd 100644 (file)
@@ -155,7 +155,7 @@ class core_calendar_repeat_event_collection_testcase extends advanced_testcase {
     protected function create_event($properties = []) {
         $record = new \stdClass();
         $record->name = 'event name';
-        $record->eventtype = 'global';
+        $record->eventtype = 'site';
         $record->repeat = 0;
         $record->repeats = 0;
         $record->timestart = time();
index ba04c38..a16bebe 100644 (file)
@@ -639,9 +639,19 @@ $CFG->admin = 'admin';
 // to check the latest default in question/classes/bank/view.php before setting this.
 //
 //      $CFG->questionbankcolumns = 'checkbox_column,question_type_column,'
-//              . 'question_name_idnumber_tags_column,tags_action_column,edit_action_column,'
-//              . 'copy_action_column,preview_action_column,delete_action_column,'
-//              . 'creator_name_column,modifier_name_column';
+//              . 'question_name_idnumber_tags_column,'
+//              . 'tags_action_column,edit_action_column,copy_action_column,'
+//              . 'preview_action_column,delete_action_column,export_xml_action_column,'
+//              . 'creator_name_column,modifier_name_column,edit_menu_column';
+//
+// Forum summary report
+//
+// In order for the forum summary report to calculate word count and character count data, those details are now stored
+// for each post in the database when posts are created or updated. For posts that existed prior to a Moodle 3.8 upgrade,
+// these are calculated by the refresh_forum_post_counts ad-hoc task in chunks of 5000 posts per batch by default.
+// That default can be overridden by setting an integer value for $CFG->forumpostcountchunksize.
+//
+//      $CFG->forumpostcountchunksize = 5000;
 //
 //=========================================================================
 // 7. SETTINGS FOR DEVELOPMENT SERVERS - not intended for production use!!!
index f2fc3e7..233fa09 100644 (file)
@@ -115,6 +115,10 @@ abstract class course_enrolments extends \core_analytics\local\target\binary {
             return get_string('coursenotyetstarted', 'course');
         }
 
+        if (!$fortraining && !$course->get_course_data()->visible) {
+            return get_string('hiddenfromstudents');
+        }
+
         if (!$this->students = $course->get_students()) {
             return get_string('nocoursestudents', 'course');
         }
index 251c460..7c247c9 100644 (file)
@@ -2973,11 +2973,7 @@ class core_course_category implements renderable, cacheable_object, IteratorAggr
      * @return bool
      */
     public function can_request_course() {
-        global $CFG;
-        if (empty($CFG->enablecourserequests) || $this->id != $CFG->defaultrequestcategory) {
-            return false;
-        }
-        return !$this->can_create_course() && has_capability('moodle/course:request', $this->get_context());
+        return course_request::can_request($this->get_context());
     }
 
     /**
index 1f12b7f..162a68d 100644 (file)
@@ -664,7 +664,7 @@ class core_course_management_renderer extends plugin_renderer_base {
         }
         if ($category->can_request_course()) {
             // Request a new course.
-            $url = new moodle_url('/course/request.php', array('return' => 'management'));
+            $url = new moodle_url('/course/request.php', array('category' => $category->id, 'return' => 'management'));
             $actions[] = html_writer::link($url, get_string('requestcourse'));
         }
         if ($category->can_resort_courses()) {
index da5d4ee..bb2b08a 100644 (file)
@@ -3619,7 +3619,7 @@ class core_course_external extends external_api {
     }
 
     /**
-     * Return structure for edit_module()
+     * Return structure for get_module()
      *
      * @since Moodle 3.3
      * @return external_description
index fbeea7a..23088e5 100644 (file)
@@ -752,16 +752,21 @@ function make_categories_options() {
 /**
  * Print the buttons relating to course requests.
  *
- * @param object $context current page context.
+ * @param context $context current page context.
  */
 function print_course_request_buttons($context) {
     global $CFG, $DB, $OUTPUT;
     if (empty($CFG->enablecourserequests)) {
         return;
     }
-    if (!has_capability('moodle/course:create', $context) && has_capability('moodle/course:request', $context)) {
-    /// Print a button to request a new course
-        echo $OUTPUT->single_button(new moodle_url('/course/request.php'), get_string('requestcourse'), 'get');
+    if (course_request::can_request($context)) {
+        // Print a button to request a new course.
+        $params = [];
+        if ($context instanceof context_coursecat) {
+            $params['category'] = $context->instanceid;
+        }
+        echo $OUTPUT->single_button(new moodle_url('/course/request.php', $params),
+            get_string('requestcourse'), 'get');
     }
     /// Print a button to manage pending requests
     if (has_capability('moodle/site:approvecourse', $context)) {
@@ -2833,7 +2838,7 @@ class course_request {
         $data->requester = $USER->id;
 
         // Setting the default category if none set.
-        if (empty($data->category) || empty($CFG->requestcategoryselection)) {
+        if (empty($data->category) || !empty($CFG->lockrequestcategory)) {
             $data->category = $CFG->defaultrequestcategory;
         }
 
@@ -2972,6 +2977,31 @@ class course_request {
         return $this->properties->collision;
     }
 
+    /**
+     * Checks user capability to approve a requested course
+     *
+     * If course was requested without category for some reason (might happen if $CFG->defaultrequestcategory is
+     * misconfigured), we check capabilities 'moodle/site:approvecourse' and 'moodle/course:changecategory'.
+     *
+     * @return bool
+     */
+    public function can_approve() {
+        global $CFG;
+        $category = null;
+        if ($this->properties->category) {
+            $category = core_course_category::get($this->properties->category, IGNORE_MISSING);
+        } else if ($CFG->defaultrequestcategory) {
+            $category = core_course_category::get($CFG->defaultrequestcategory, IGNORE_MISSING);
+        }
+        if ($category) {
+            return has_capability('moodle/site:approvecourse', $category->get_context());
+        }
+
+        // We can not determine the context where the course should be created. The approver should have
+        // both capabilities to approve courses and change course category in the system context.
+        return has_all_capabilities(['moodle/site:approvecourse', 'moodle/course:changecategory'], context_system::instance());
+    }
+
     /**
      * Returns the category where this course request should be created
      *
@@ -2983,17 +3013,14 @@ class course_request {
      */
     public function get_category() {
         global $CFG;
-        // If the category is not set, if the current user does not have the rights to change the category, or if the
-        // category does not exist, we set the default category to the course to be approved.
-        // The system level is used because the capability moodle/site:approvecourse is based on a system level.
-        if (empty($this->properties->category) || !has_capability('moodle/course:changecategory', context_system::instance()) ||
-                (!$category = core_course_category::get($this->properties->category, IGNORE_MISSING, true))) {
-            $category = core_course_category::get($CFG->defaultrequestcategory, IGNORE_MISSING, true);
-        }
-        if (!$category) {
-            $category = core_course_category::get_default();
+        if ($this->properties->category && ($category = core_course_category::get($this->properties->category, IGNORE_MISSING))) {
+            return $category;
+        } else if ($CFG->defaultrequestcategory &&
+                ($category = core_course_category::get($CFG->defaultrequestcategory, IGNORE_MISSING))) {
+            return $category;
+        } else {
+            return core_course_category::get_default();
         }
-        return $category;
     }
 
     /**
@@ -3119,6 +3146,33 @@ class course_request {
         $eventdata->notification      = 1;
         message_send($eventdata);
     }
+
+    /**
+     * Checks if current user can request a course in this context
+     *
+     * @param context $context
+     * @return bool
+     */
+    public static function can_request(context $context) {
+        global $CFG;
+        if (empty($CFG->enablecourserequests)) {
+            return false;
+        }
+        if (has_capability('moodle/course:create', $context)) {
+            return false;
+        }
+
+        if ($context instanceof context_system) {
+            $defaultcontext = context_coursecat::instance($CFG->defaultrequestcategory, IGNORE_MISSING);
+            return $defaultcontext &&
+                has_capability('moodle/course:request', $defaultcontext);
+        } else if ($context instanceof context_coursecat) {
+            if (!$CFG->lockrequestcategory || $CFG->defaultrequestcategory == $context->instanceid) {
+                return has_capability('moodle/course:request', $context);
+            }
+        }
+        return false;
+    }
 }
 
 /**
index 710ca55..85a59f4 100644 (file)
@@ -39,7 +39,20 @@ $approve = optional_param('approve', 0, PARAM_INT);
 $reject = optional_param('reject', 0, PARAM_INT);
 
 $baseurl = $CFG->wwwroot . '/course/pending.php';
-admin_externalpage_setup('coursespending');
+$context = context_system::instance();
+if (has_capability('moodle/site:approvecourse', $context)) {
+    // Similar to course management capabilities, if user has approve capability in system context
+    // we add the link to the admin menu. Otherwise we check if user has capability anywhere.
+    admin_externalpage_setup('coursespending');
+} else {
+    require_login(null, false);
+    $categories = core_course_category::make_categories_list('moodle/site:approvecourse');
+    if (!$categories) {
+        require_capability('moodle/site:approvecourse', $context);
+    }
+    $PAGE->set_context($context);
+    $PAGE->set_url(new moodle_url('/course/pending.php'));
+}
 
 /// Process approval of a course.
 if (!empty($approve) and confirm_sesskey()) {
@@ -48,7 +61,11 @@ if (!empty($approve) and confirm_sesskey()) {
     $courseid = $course->approve();
 
     if ($courseid !== false) {
-        redirect(new moodle_url('/course/edit.php', ['id' => $courseid, 'returnto' => 'pending']));
+        if (has_capability('moodle/course:update', context_course::instance($courseid))) {
+            redirect(new moodle_url('/course/edit.php', ['id' => $courseid, 'returnto' => 'pending']));
+        } else {
+            redirect(new moodle_url('/course/view.php', ['id' => $courseid]));
+        }
     } else {
         print_error('courseapprovedfailed');
     }
@@ -109,6 +126,9 @@ if (empty($pending)) {
         // Check here for shortname collisions and warn about them.
         $course->check_shortname_collision();
 
+        if (!$course->can_approve()) {
+            continue;
+        }
         $category = $course->get_category();
 
         $row = array();
index e330e1d..3b1813b 100644 (file)
@@ -30,6 +30,7 @@ require_once($CFG->dirroot . '/course/request_form.php');
 // Where we came from. Used in a number of redirects.
 $url = new moodle_url('/course/request.php');
 $return = optional_param('return', null, PARAM_ALPHANUMEXT);
+$categoryid = optional_param('category', null, PARAM_INT);
 if ($return === 'management') {
     $url->param('return', $return);
     $returnurl = new moodle_url('/course/management.php', array('categoryid' => $CFG->defaultrequestcategory));
@@ -47,12 +48,24 @@ if (isguestuser()) {
 if (empty($CFG->enablecourserequests)) {
     print_error('courserequestdisabled', '', $returnurl);
 }
-$context = context_system::instance();
+
+if ($CFG->lockrequestcategory) {
+    // Course request category is locked, user will always request in the default request category.
+    $categoryid = null;
+} else if (!$categoryid) {
+    // Category selection is enabled but category is not specified.
+    // Find a category where user has capability to request courses (preferably the default category).
+    $list = core_course_category::make_categories_list('moodle/course:request');
+    $categoryid = array_key_exists($CFG->defaultrequestcategory, $list) ? $CFG->defaultrequestcategory : key($list);
+}
+
+$context = context_coursecat::instance($categoryid ?: $CFG->defaultrequestcategory);
 $PAGE->set_context($context);
 require_capability('moodle/course:request', $context);
 
 // Set up the form.
-$data = course_request::prepare();
+$data = $categoryid ? (object)['category' => $categoryid] : null;
+$data = course_request::prepare($data);
 $requestform = new course_request_form($url);
 $requestform->set_data($data);
 
index 054149d..3107584 100644 (file)
@@ -68,8 +68,8 @@ class course_request_form extends moodleform {
         $mform->addRule('shortname', get_string('missingshortname'), 'required', null, 'client');
         $mform->setType('shortname', PARAM_TEXT);
 
-        if (!empty($CFG->requestcategoryselection)) {
-            $displaylist = core_course_category::make_categories_list();
+        if (empty($CFG->lockrequestcategory)) {
+            $displaylist = core_course_category::make_categories_list('moodle/course:request');
             $mform->addElement('select', 'category', get_string('coursecategory'), $displaylist);
             $mform->setDefault('category', $CFG->defaultrequestcategory);
             $mform->addHelpButton('category', 'coursecategory');
diff --git a/course/tests/behat/course_request.feature b/course/tests/behat/course_request.feature
new file mode 100644 (file)
index 0000000..c50671f
--- /dev/null
@@ -0,0 +1,106 @@
+@core @core_course
+Feature: Users can request and approve courses
+  As a moodle admin
+  In order to improve course creation process
+  I need to be able to enable course approval
+
+  Background:
+    Given the following "users" exist:
+      | username | firstname | lastname | email |
+      | user1 | User | 1 | user1@example.com |
+      | user2 | User | 2 | user2@example.com |
+      | user3 | User | 3 | user3@example.com |
+
+  Scenario: Simple course request workflow
+    Given the following "system role assigns" exist:
+      | user  | course | role |
+      | user2 | Acceptance test site | manager |
+    Given I log in as "admin"
+    And I set the following administration settings values:
+      | lockrequestcategory | 1 |
+    And I set the following system permissions of "Authenticated user" role:
+      | capability | permission |
+      | moodle/course:request | Allow |
+    And I log out
+    When I log in as "user1"
+    And I am on course index
+    And I press "Request a course"
+    And I set the following fields to these values:
+      | Course full name  | My new course |
+      | Course short name | Mynewcourse   |
+      | Supporting information | pretty please |
+    And I press "Request a course"
+    And I should see "Your course request has been saved successfully."
+    And I press "Continue"
+    And I am on course index
+    And I should not see "My new course"
+    And I log out
+    And I log in as "user2"
+    And I am on course index
+    And I press "Courses pending approval"
+    And I should see "Miscellaneous" in the "My new course" "table_row"
+    And I click on "Approve" "button" in the "My new course" "table_row"
+    And I press "Save and return"
+    And I should see "There are no courses pending approval"
+    And I press "Back to course listing"
+    And I should see "My new course"
+    And I log out
+    And I log in as "user1"
+    And I am on course index
+    And I follow "My new course"
+    And I navigate to course participants
+    And I should see "Teacher" in the "User 1" "table_row"
+    And I log out
+
+  Scenario: Course request with category selection
+    Given the following "categories" exist:
+      | name             | category | idnumber |
+      | Science category | 0        | SCI |
+      | English category | 0        | ENG |
+      | Other category   | 0        | MISC |
+    Given the following "roles" exist:
+      | name             | shortname       | description      | archetype      |
+      | Course requestor | courserequestor | My custom role 1 |                |
+    And the following "role assigns" exist:
+      | user  | role            | contextlevel | reference |
+      | user1 | courserequestor | Category     | SCI       |
+      | user1 | courserequestor | Category     | ENG       |
+      | user2 | manager         | Category     | SCI       |
+      | user3 | manager         | Category     | ENG       |
+    Given I log in as "admin"
+    And I set the following system permissions of "Course requestor" role:
+      | capability            | permission |
+      | moodle/course:request | Allow      |
+    And I log out
+    And I log in as "user1"
+    And I am on course index
+    And I follow "English category"
+    And I press "Request a course"
+    And the field "Course category" matches value "English category"
+    And I set the following fields to these values:
+      | Course full name  | My new course |
+      | Course short name | Mynewcourse   |
+      | Supporting information | pretty please |
+    And I press "Request a course"
+    And I log out
+    And I log in as "user2"
+    And I am on course index
+    And I follow "English category"
+    And "Courses pending approval" "button" should not exist
+    And I am on course index
+    And I follow "Science category"
+    And I press "Courses pending approval"
+    And I should not see "Mynewcourse"
+    And I press "Back to course listing"
+    And I log out
+    And I log in as "user3"
+    And I am on course index
+    And I follow "English category"
+    And I press "Courses pending approval"
+    And I should see "English category" in the "Mynewcourse" "table_row"
+    And I click on "Approve" "button" in the "Mynewcourse" "table_row"
+    And I press "Save and return"
+    And I am on course index
+    And I follow "English category"
+    And I should see "My new course"
+    And I log out
index 6779405..c5b37fc 100644 (file)
@@ -6811,4 +6811,110 @@ class core_course_courselib_testcase extends advanced_testcase {
         course_delete_module($moduleinstances[$indextodelete]->cmid, true); // Try to delete the instance asynchronously.
         $this->assertEquals($expected, course_modules_pending_deletion($course->id, $gradable));
     }
+
+    /**
+     * Tests for the course_request::can_request
+     */
+    public function test_can_request_course() {
+        global $CFG, $DB;
+        $this->resetAfterTest();
+
+        $user = $this->getDataGenerator()->create_user();
+        $cat1 = $CFG->defaultrequestcategory;
+        $cat2 = $this->getDataGenerator()->create_category()->id;
+        $cat3 = $this->getDataGenerator()->create_category()->id;
+        $context1 = context_coursecat::instance($cat1);
+        $context2 = context_coursecat::instance($cat2);
+        $context3 = context_coursecat::instance($cat3);
+        $this->setUser($user);
+
+        // By default users don't have capability to request courses.
+        $this->assertFalse(course_request::can_request(context_system::instance()));
+        $this->assertFalse(course_request::can_request($context1));
+        $this->assertFalse(course_request::can_request($context2));
+        $this->assertFalse(course_request::can_request($context3));
+
+        // Allow for the 'user' role the capability to request courses.
+        $userroleid = $DB->get_field('role', 'id', ['shortname' => 'user']);
+        assign_capability('moodle/course:request', CAP_ALLOW, $userroleid,
+            context_system::instance()->id);
+        accesslib_clear_all_caches_for_unit_testing();
+
+        // Lock category selection.
+        $CFG->lockrequestcategory = 1;
+
+        // Now user can only request course in the default category or in system context.
+        $this->assertTrue(course_request::can_request(context_system::instance()));
+        $this->assertTrue(course_request::can_request($context1));
+        $this->assertFalse(course_request::can_request($context2));
+        $this->assertFalse(course_request::can_request($context3));
+
+        // Enable category selection. User can request course anywhere.
+        $CFG->lockrequestcategory = 0;
+        $this->assertTrue(course_request::can_request(context_system::instance()));
+        $this->assertTrue(course_request::can_request($context1));
+        $this->assertTrue(course_request::can_request($context2));
+        $this->assertTrue(course_request::can_request($context3));
+
+        // Remove cap from cat2.
+        $roleid = create_role('Test role', 'testrole', 'Test role description');
+        assign_capability('moodle/course:request', CAP_PROHIBIT, $roleid,
+            $context2->id, true);
+        role_assign($roleid, $user->id, $context2->id);
+        accesslib_clear_all_caches_for_unit_testing();
+
+        $this->assertTrue(course_request::can_request(context_system::instance()));
+        $this->assertTrue(course_request::can_request($context1));
+        $this->assertFalse(course_request::can_request($context2));
+        $this->assertTrue(course_request::can_request($context3));
+
+        // Disable course request functionality.
+        $CFG->enablecourserequests = false;
+        $this->assertFalse(course_request::can_request(context_system::instance()));
+        $this->assertFalse(course_request::can_request($context1));
+        $this->assertFalse(course_request::can_request($context2));
+        $this->assertFalse(course_request::can_request($context3));
+    }
+
+    /**
+     * Tests for the course_request::can_approve
+     */
+    public function test_can_approve_course_request() {
+        global $CFG;
+        $this->resetAfterTest();
+
+        $requestor = $this->getDataGenerator()->create_user();
+        $user = $this->getDataGenerator()->create_user();
+        $cat1 = $CFG->defaultrequestcategory;
+        $cat2 = $this->getDataGenerator()->create_category()->id;
+        $cat3 = $this->getDataGenerator()->create_category()->id;
+
+        // Enable course requests. Default 'user' role has capability to request courses.
+        $CFG->enablecourserequests = true;
+        $CFG->lockrequestcategory = 0;
+        $this->setUser($requestor);
+        $requestdata = ['summary_editor' => ['text' => '', 'format' => 0], 'name' => 'Req', 'reason' => 'test'];
+        $request1 = course_request::create((object)($requestdata));
+        $request2 = course_request::create((object)($requestdata + ['category' => $cat2]));
+        $request3 = course_request::create((object)($requestdata + ['category' => $cat3]));
+
+        $this->setUser($user);
+        // Add capability to approve courses.
+        $roleid = create_role('Test role', 'testrole', 'Test role description');
+        assign_capability('moodle/site:approvecourse', CAP_ALLOW, $roleid,
+            context_system::instance()->id, true);
+        role_assign($roleid, $user->id, context_coursecat::instance($cat2)->id);
+        accesslib_clear_all_caches_for_unit_testing();
+
+        $this->assertFalse($request1->can_approve());
+        $this->assertTrue($request2->can_approve());
+        $this->assertFalse($request3->can_approve());
+
+        // Delete category where course was requested. Now only site-wide manager can approve it.
+        core_course_category::get($cat2, MUST_EXIST, true)->delete_full(false);
+        $this->assertFalse($request2->can_approve());
+
+        $this->setAdminUser();
+        $this->assertTrue($request2->can_approve());
+    }
 }
index 1e96d21..fa76479 100644 (file)
@@ -37,7 +37,7 @@ class core_course_courserequest_testcase extends advanced_testcase {
 
         $defaultcategory = $DB->get_field_select('course_categories', "MIN(id)", "parent=0");
         set_config('enablecourserequests', 1);
-        set_config('requestcategoryselection', 0);
+        set_config('lockrequestcategory', 1);
         set_config('defaultrequestcategory', $defaultcategory);
 
         // Create some categories.
@@ -70,7 +70,7 @@ class core_course_courserequest_testcase extends advanced_testcase {
 
         // Request with category different than default and category selection allowed.
         set_config('defaultrequestcategory', $cat3->id);
-        set_config('requestcategoryselection', 1);
+        set_config('lockrequestcategory', 0);
         $data->category = $cat1->id;
         $cr = course_request::create($data);
         $this->assertEquals($cat1->id, $cr->category);
@@ -83,14 +83,20 @@ class core_course_courserequest_testcase extends advanced_testcase {
 
         $defaultcategory = $DB->get_field_select('course_categories', "MIN(id)", "parent=0");
         set_config('enablecourserequests', 1);
-        set_config('requestcategoryselection', 0);
+        set_config('lockrequestcategory', 1);
         set_config('defaultrequestcategory', $defaultcategory);
 
         // Create some categories.
         $cat1 = $this->getDataGenerator()->create_category();
         $cat2 = $this->getDataGenerator()->create_category();
 
+        // Create a user and allow course requests for him.
         $requester = $this->getDataGenerator()->create_user();
+        $roleid = create_role('Course requestor role', 'courserequestor', '');
+        assign_capability('moodle/course:request', CAP_ALLOW, $roleid,
+            context_system::instance()->id);
+        role_assign($roleid, $requester->id, context_system::instance()->id);
+        accesslib_clear_all_caches_for_unit_testing();
 
         $data = new stdClass();
         $data->fullname = 'Həllo World!';
@@ -116,7 +122,7 @@ class core_course_courserequest_testcase extends advanced_testcase {
         $this->assertEquals($defaultcategory, $course->category);
 
         // Test with category.
-        set_config('requestcategoryselection', 1);
+        set_config('lockrequestcategory', 0);
         set_config('defaultrequestcategory', $cat2->id);
         $data->shortname .= ' 2nd';
         $data->category = $cat1->id;
@@ -138,10 +144,16 @@ class core_course_courserequest_testcase extends advanced_testcase {
 
         $this->setAdminUser();
         set_config('enablecourserequests', 1);
-        set_config('requestcategoryselection', 0);
+        set_config('lockrequestcategory', 1);
         set_config('defaultrequestcategory', $DB->get_field_select('course_categories', "MIN(id)", "parent=0"));
 
+        // Create a user and allow course requests for him.
         $requester = $this->getDataGenerator()->create_user();
+        $roleid = create_role('Course requestor role', 'courserequestor', '');
+        assign_capability('moodle/course:request', CAP_ALLOW, $roleid,
+            context_system::instance()->id);
+        role_assign($roleid, $requester->id, context_system::instance()->id);
+        accesslib_clear_all_caches_for_unit_testing();
 
         $data = new stdClass();
         $data->fullname = 'Həllo World!';
index 7c9a1d1..90a9710 100644 (file)
@@ -130,6 +130,27 @@ class core_analytics_targets_testcase extends advanced_testcase {
                 ],
                 'isvalid' => get_string('completionnotenabledforcourse', 'completion')
             ],
+            'coursehiddentraining' => [
+                'params' => [
+                    'enablecompletion' => 1,
+                    'startdate' => mktime(0, 0, 0, $month - 1, 24, $year - 1),
+                    'enddate' => mktime(0, 0, 0, $month - 1, 23, $year),
+                    'students' => true,
+                    'visible' => '0',
+                ],
+                'isvalid' => true,
+            ],
+            'coursehiddenprediction' => [
+                'params' => [
+                    'enablecompletion' => 1,
+                    'startdate' => mktime(0, 0, 0, $month - 1, 24, $year),
+                    'enddate' => mktime(0, 0, 0, $month - 1, 23, $year + 1),
+                    'students' => true,
+                    'visible' => '0',
+                ],
+                'isvalid' => get_string('hiddenfromstudents'),
+                'fortraining' => false
+            ],
         ];
     }
 
index 3a51249..b49c0a3 100644 (file)
@@ -203,7 +203,7 @@ $string['configdebugdisplay'] = 'Set to on, the error reporting will go to the H
 $string['configdebugpageinfo'] = 'Enable if you want page information printed in page footer.';
 $string['configdebugvalidators'] = 'Enable if you want to have links to external validator servers in page footer. You may need to create new user with username <em>w3cvalidator</em>, and enable guest access. These changes may allow unauthorized access to server, do not enable on production sites!';
 $string['configdefaulthomepage'] = 'This determines the first link in the navigation for logged-in users.';
-$string['configdefaultrequestcategory'] = 'Courses requested by users will be automatically placed in this category.';
+$string['configdefaultrequestcategory'] = 'Courses requested by users will be placed in this category if the category is not specified.';
 $string['configdefaultrequestedcategory'] = 'Default category to put courses that were requested into, if they\'re approved.';
 $string['configdefaultuserroleid'] = 'All logged in users will be given the capabilities of the role you specify here, at the site level, in ADDITION to any other roles they may have been given.  The default is the Authenticated user role.  Note that this will not conflict with other roles they have unless you prohibit capabilities, it just ensures that all users have capabilities that are not assignable at the course level (eg post blog entries, manage own calendar, etc).';
 $string['configdeleteincompleteusers'] = 'After this period, any account without the first name, last name or email field filled in is deleted.';
@@ -225,7 +225,7 @@ $string['configemailfromvia'] = 'Add via information in the "From" section of ou
 $string['configemailsubjectprefix'] = 'Text to be prefixed to the subject line of all outgoing mail.';
 $string['configenablecalendarexport'] = 'Enable exporting or subscribing to calendars.';
 $string['configenablecomments'] = 'Enable comments';
-$string['configenablecourserequests'] = 'This will allow any user to request a course be created.';
+$string['configenablecourserequests'] = 'Enable course request functionality. Users with capability to request courses but without capability to create courses will be able to request courses.';
 $string['configenablemobilewebservice'] = 'Enable mobile service for the official Moodle app or other app requesting it. For more information, read the {$a}';
 $string['configenablerssfeeds'] = 'If enabled, RSS feeds are generated by various features across the site, such as blogs, forums, database activities and glossaries. Note that RSS feeds also need to be enabled for the particular activity modules.';
 $string['configenablerssfeedsdisabled'] = 'It is not available because RSS feeds are disabled in all the Site. To enable them, go to the Variables settings under Admin Configuration.';
@@ -270,6 +270,7 @@ $string['configlanglist'] = 'If left blank, all languages installed on the site
 $string['configlangmenu'] = 'Choose whether or not you want to display the general-purpose language menu on the home page, login page etc.  This does not affect the user\'s ability to set the preferred language in their own profile.';
 $string['configlatinexcelexport'] = 'Choose the encoding for Excel exports.';
 $string['configlocale'] = 'Choose a sitewide locale - this will override the format and language of dates for all language packs (though names of days in calendar are not affected). You need to have this locale data installed on your operating system (eg for linux en_US.UTF-8 or es_ES.UTF-8). In most cases this field should be left blank.';
+$string['configlockrequestcategory'] = 'Only allow course requests in the default course request category. This is a legacy setting, it is better not to use it but instead assign capability to request courses in the appropriate course category context';
 $string['configloglifetime'] = 'This specifies the length of time you want to keep logs about user activity.  Logs that are older than this age are automatically deleted.  It is best to keep logs as long as possible, in case you need them, but if you have a very busy server and are experiencing performance problems, then you may want to lower the log lifetime. Values lower than 30 are not recommended because statistics may not work properly.';
 $string['configlookahead'] = 'Days to look ahead';
 $string['configmailnewline'] = 'Newline characters used in mail messages. CRLF is required according to RFC 822bis, some mail servers do automatic conversion from LF to CRLF, other mail servers do incorrect conversion from CRLF to CRCRLF, yet others reject mails with bare LF (qmail for example). Try changing this setting if you are having problems with undelivered emails or double newlines.';
@@ -317,7 +318,6 @@ $string['configproxytype'] = 'Type of web proxy (PHP5 and cURL extension require
 $string['configproxyuser'] = 'Username needed to access internet through proxy if required, empty if none (PHP cURL extension required).';
 $string['configrecaptchaprivatekey'] = 'String of characters (secret key) used to communicate between your Moodle server and the recaptcha server. ReCAPTCHA keys can be obtained from <a target="_blank" href="https://www.google.com/recaptcha">Google reCAPTCHA</a>.';
 $string['configrecaptchapublickey'] = 'String of characters (site key) used to display the reCAPTCHA element in the signup form. ReCAPTCHA keys can be obtained from <a target="_blank" href="https://www.google.com/recaptcha">Google reCAPTCHA</a>.';
-$string['configrequestcategoryselection'] = 'Allow the selection of a category when requesting a course.';
 $string['configrequestedstudentname'] = 'Word for student used in requested courses';
 $string['configrequestedstudentsname'] = 'Word for students used in requested courses';
 $string['configrequestedteachername'] = 'Word for teacher used in requested courses';
@@ -716,6 +716,7 @@ $string['lockoutthreshold'] = 'Account lockout threshold';
 $string['lockoutthreshold_desc'] = 'Select number of failed login attempts that result in account lockout. This feature may be abused in denial of service attacks.';
 $string['lockoutwindow'] = 'Account lockout observation window';
 $string['lockoutwindow_desc'] = 'Observation time for lockout threshold, if there are no failed attempts the threshold counter is reset after this time.';
+$string['lockrequestcategory'] = 'Lock category for the course requests';
 $string['log'] = 'Logs';
 $string['logguests'] = 'Log guest access';
 $string['logguests_help'] = 'This setting enables logging of actions by guest account and not logged in users. High profile sites may want to disable this logging for performance reasons. It is recommended to keep this setting enabled on production sites.';
@@ -1045,7 +1046,6 @@ $string['purgeselectedcaches'] = 'Purge selected caches';
 $string['purgeselectedcachesfinished'] = 'The selected caches were purged.';
 $string['purgetemplates'] = 'Templates';
 $string['purgethemecache'] = 'Themes';
-$string['requestcategoryselection'] = 'Enable category selection';
 $string['restorecourse'] = 'Restore course';
 $string['restorernewroleid'] = 'Restorers\' role in courses';
 $string['restorernewroleid_help'] = 'If the user does not already have the permission to manage the newly restored course, the user is automatically assigned this role and enrolled if necessary. Select "None" if you do not want restorers to be able to manage every restored course.';
@@ -1436,3 +1436,5 @@ $string['registermoodleorg'] = 'When you register your site';
 $string['registermoodleorgli1'] = 'You are added to a low-volume mailing list for important notifications such as security alerts and new releases of Moodle.';
 $string['registermoodleorgli2'] = 'Statistics about your site will be added to the {$a} of the worldwide Moodle community.';
 $string['registerwithmoodleorg'] = 'Register your site';
+$string['configrequestcategoryselection'] = 'Allow the selection of a category when requesting a course.';
+$string['requestcategoryselection'] = 'Enable category selection';
index 1c72cb1..c32225a 100644 (file)
@@ -46,8 +46,10 @@ $string['errorimportmissingcomponents'] = 'The provided model requires the follo
 $string['errorimportversionmismatches'] = 'The version of the following components differs from the version installed on this site: {$a}. You can use the option \'Ignore version mismatches\' to ignore these differences.';
 $string['errorimportmissingclasses'] = 'The following analytics components are not available on this site: {$a->missingclasses}.';
 $string['errorinvalidindicator'] = 'Invalid {$a} indicator';
+$string['errorinvalidcontexts'] = 'Some of the selected contexts can not be used in this target.';
 $string['errorinvalidtarget'] = 'Invalid {$a} target';
 $string['errorinvalidtimesplitting'] = 'Invalid analysis interval; please ensure you add the fully qualified class name.';
+$string['errornocontextrestrictions'] = 'The selected target does not support context restrictions';
 $string['errornoexportconfig'] = 'There was a problem exporting the model configuration.';
 $string['errornoexportconfigrequirements'] = 'Only non-static models with an analysis interval can be exported.';
 $string['errornoindicators'] = 'This model does not have any indicators.';
index 8ed97c4..cea9d7e 100644 (file)
@@ -32,6 +32,7 @@ $string['calendarheading'] = '{$a} Calendar';
 $string['calendarpreferences'] = 'Calendar preferences';
 $string['calendartypes'] = 'Calendar types';
 $string['calendarurl'] = 'Calendar URL: {$a}';
+$string['category'] = 'Category';
 $string['categoryevent'] = 'Category event';
 $string['clickhide'] = 'click to hide';
 $string['clickshow'] = 'click to show';
@@ -142,16 +143,12 @@ $string['forcecalendartype'] = 'Force calendar';
 $string['fri'] = 'Fri';
 $string['friday'] = 'Friday';
 $string['generateurlbutton'] = 'Get calendar URL';
-$string['global'] = 'Global';
-$string['globalevent'] = 'Global event';
-$string['globalevents'] = 'Global events';
 $string['gotoactivity'] = 'Go to activity';
 $string['gotocalendar'] = 'Go to calendar';
 $string['group'] = 'Group';
 $string['groupevent'] = 'Group event';
 $string['groupevents'] = 'Group events';
-$string['eventtypeglobal'] = 'global';
-$string['eventtypesite'] = 'global';
+$string['eventtypesite'] = 'site';
 $string['eventtypecategory'] = 'category';
 $string['eventtypecourse'] = 'course';
 $string['eventtypemodule'] = 'module';
@@ -225,6 +222,8 @@ $string['repeatweeksr'] = 'events';
 $string['sat'] = 'Sat';
 $string['saturday'] = 'Saturday';
 $string['shown'] = 'shown';
+$string['site'] = 'Site';
+$string['siteevent'] = 'Site event';
 $string['siteevents'] = 'Site events';
 $string['spanningevents'] = 'Events underway';
 $string['subscriptions'] = 'Subscriptions';
@@ -271,3 +270,9 @@ $string['when'] = 'When';
 $string['whendate'] = 'When: {$a}';
 $string['yesterday'] = 'Yesterday';
 $string['youcandeleteallrepeats'] = 'This event is part of a repeating event series. You can delete this event only, or all {$a} events in the series at once.';
+
+// Deprecated since Moodle 3.8.
+$string['global'] = 'Global';
+$string['globalevent'] = 'Global event';
+$string['globalevents'] = 'Global events';
+$string['eventtypeglobal'] = 'global';
index 0595e0c..f343ba6 100644 (file)
@@ -115,3 +115,9 @@ completeregistration,core_hub
 registersite,core_hub
 updatesite,core_hub
 unregisterexplained,core_hub
+configrequestcategoryselection,core_admin
+requestcategoryselection,core_admin
+global,core_calendar
+globalevent,core_calendar
+globalevents,core_calendar
+eventtypeglobal,core_calendar
index 8da8aa4..94cc9df 100644 (file)
@@ -157,6 +157,7 @@ $string['eventquestionsexported'] = 'Questions exported';
 $string['eventquestionsimported'] = 'Questions imported';
 $string['eventquestionupdated'] = 'Question updated';
 $string['export'] = 'Export';
+$string['exportasxml'] = 'Export as Moodle XML';
 $string['exportcategory'] = 'Export category';
 $string['exportcategory_help'] = 'This setting determines the category from which the exported questions will be taken.
 
index 40ba586..76bf394 100644 (file)
@@ -39,14 +39,15 @@ class users extends \core_analytics\local\analyser\base {
      * The site users are the analysable elements returned by this analyser.
      *
      * @param string|null $action 'prediction', 'training' or null if no specific action needed.
+     * @param \context[] $contexts Only analysables that depend on the provided contexts. All analysables in the system if empty.
      * @return \Iterator
      */
-    public function get_analysables_iterator(?string $action = null) {
+    public function get_analysables_iterator(?string $action = null, array $contexts = []) {
         global $DB, $CFG;
 
         $siteadmins = explode(',', $CFG->siteadmins);
 
-        list($sql, $params) = $this->get_iterator_sql('user', CONTEXT_USER, $action, 'u');
+        list($sql, $params) = $this->get_iterator_sql('user', CONTEXT_USER, $action, 'u', $contexts);
 
         $sql .= " AND u.deleted = :deleted AND u.confirmed = :confirmed AND u.suspended = :suspended";
         $params = $params + ['deleted' => 0, 'confirmed' => 1, 'suspended' => 0];
index 5bbb841..c344680 100644 (file)
@@ -210,7 +210,7 @@ class icon_system_fontawesome extends icon_system_font {
             'core:i/lock' => 'fa-lock',
             'core:i/categoryevent' => 'fa-cubes',
             'core:i/course' => 'fa-graduation-cap',
-            'core:i/courseevent' => 'fa-university',
+            'core:i/courseevent' => 'fa-graduation-cap',
             'core:i/customfield' => 'fa-hand-o-right',
             'core:i/db' => 'fa-database',
             'core:i/delete' => 'fa-trash',
index bcf2cc9..c8d7085 100644 (file)
@@ -1123,8 +1123,7 @@ class core_useragent {
         $extension = strtolower($extension);
 
         $supportedvideo = array('m4v', 'webm', 'ogv', 'mp4', 'mov');
-        $supportedaudio = array('ogg', 'oga', 'aac', 'm4a', 'mp3', 'wav');
-        // TODO MDL-56549 Flac will be supported in Firefox 51 in January 2017.
+        $supportedaudio = array('ogg', 'oga', 'aac', 'm4a', 'mp3', 'wav', 'flac');
 
         // Basic extension support.
         if (!in_array($extension, $supportedvideo) && !in_array($extension, $supportedaudio)) {
@@ -1158,6 +1157,11 @@ class core_useragent {
         if ($isogg && (self::is_ie() || self::is_edge() || self::is_safari() || self::is_safari_ios())) {
             return false;
         }
+        // FLAC is not supported in IE and Edge (below 16.0).
+        if ($extension === 'flac' &&
+                (self::is_ie() || (self::is_edge() && !self::check_edge_version('16.0')))) {
+            return false;
+        }
         // Wave is not supported in IE.
         if ($extension === 'wav' && self::is_ie()) {
             return false;
index 4253f7d..47a0392 100644 (file)
@@ -133,7 +133,7 @@ $capabilities = array(
         'riskbitmask' => RISK_XSS,
 
         'captype' => 'write',
-        'contextlevel' => CONTEXT_SYSTEM,
+        'contextlevel' => CONTEXT_COURSECAT,
         'archetypes' => array(
             'manager' => CAP_ALLOW
         )
@@ -782,10 +782,7 @@ $capabilities = array(
 
     'moodle/course:request' => array(
         'captype' => 'write',
-        'contextlevel' => CONTEXT_SYSTEM,
-        'archetypes' => array(
-            'user' => CAP_ALLOW,
-        )
+        'contextlevel' => CONTEXT_COURSECAT,
     ),
 
     'moodle/course:delete' => array(
index b762bfc..a2e970f 100644 (file)
         <FIELD NAME="timesplitting" TYPE="char" LENGTH="255" NOTNULL="false" SEQUENCE="false"/>
         <FIELD NAME="predictionsprocessor" TYPE="char" LENGTH="255" NOTNULL="false" SEQUENCE="false"/>
         <FIELD NAME="version" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="false"/>
+        <FIELD NAME="contextids" TYPE="text" NOTNULL="false" SEQUENCE="false" COMMENT="The model will be restricted to this contexts"/>
         <FIELD NAME="timecreated" TYPE="int" LENGTH="10" NOTNULL="false" SEQUENCE="false"/>
         <FIELD NAME="timemodified" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="false"/>
         <FIELD NAME="usermodified" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="false"/>
index 9355360..d4c526e 100644 (file)
@@ -3611,5 +3611,32 @@ function xmldb_main_upgrade($oldversion) {
         upgrade_main_savepoint(true, 2019100900.00);
     }
 
+    if ($oldversion < 2019101600.01) {
+
+        // Change the setting $CFG->requestcategoryselection into $CFG->lockrequestcategory with opposite value.
+        set_config('lockrequestcategory', !$CFG->requestcategoryselection);
+
+        upgrade_main_savepoint(true, 2019101600.01);
+    }
+
+    if ($oldversion < 2019101800.02) {
+
+        // Get the table by its previous name.
+        $table = new xmldb_table('analytics_models');
+        if ($dbman->table_exists($table)) {
+
+            // Define field contextids to be added to analytics_models.
+            $field = new xmldb_field('contextids', XMLDB_TYPE_TEXT, null, null, null, null, null, 'version');
+
+            // Conditionally launch add field contextids.
+            if (!$dbman->field_exists($table, $field)) {
+                $dbman->add_field($table, $field);
+            }
+        }
+
+        // Main savepoint reached.
+        upgrade_main_savepoint(true, 2019101800.02);
+    }
+
     return true;
 }
index da095b9..048b119 100644 (file)
@@ -97,11 +97,6 @@ class MoodleQuickForm_date_selector extends MoodleQuickForm_group {
                 }
             }
         }
-
-        // The YUI2 calendar only supports the gregorian calendar type.
-        if ($calendartype->get_name() === 'gregorian') {
-            form_init_date_js();
-        }
     }
 
     /**
@@ -256,9 +251,21 @@ class MoodleQuickForm_date_selector extends MoodleQuickForm_group {
      * @param string $error An error message associated with a group
      */
     function accept(&$renderer, $required = false, $error = null) {
+        form_init_date_js();
         $renderer->renderElement($this, $required, $error);
     }
 
+    /**
+     * Export for template
+     *
+     * @param renderer_base $output
+     * @return array|stdClass
+     */
+    public function export_for_template(renderer_base $output) {
+        form_init_date_js();
+        return parent::export_for_template($output);
+    }
+
     /**
      * Output a timestamp. Give it the name of the group.
      *
index 311e233..094b05f 100644 (file)
@@ -100,11 +100,6 @@ class MoodleQuickForm_date_time_selector extends MoodleQuickForm_group {
                 }
             }
         }
-
-        // The YUI2 calendar only supports the gregorian calendar type.
-        if ($calendartype->get_name() === 'gregorian') {
-            form_init_date_js();
-        }
     }
 
     /**
@@ -282,9 +277,21 @@ class MoodleQuickForm_date_time_selector extends MoodleQuickForm_group {
      * @param string $error An error message associated with a group
      */
     function accept(&$renderer, $required = false, $error = null) {
+        form_init_date_js();
         $renderer->renderElement($this, $required, $error);
     }
 
+    /**
+     * Export for template
+     *
+     * @param renderer_base $output
+     * @return array|stdClass
+     */
+    public function export_for_template(renderer_base $output) {
+        form_init_date_js();
+        return parent::export_for_template($output);
+    }
+
     /**
      * Output a timestamp. Give it the name of the group.
      *
index 2dd9b33..990ab73 100644 (file)
@@ -77,7 +77,12 @@ function form_init_date_js() {
     global $PAGE;
     static $done = false;
     if (!$done) {
+        $done = true;
         $calendar = \core_calendar\type_factory::get_calendar_instance();
+        if ($calendar->get_name() !== 'gregorian') {
+            // The YUI2 calendar only supports the gregorian calendar type.
+            return;
+        }
         $module   = 'moodle-form-dateselector';
         $function = 'M.form.dateselector.init_date_selectors';
         $defaulttimezone = date_default_timezone_get();
@@ -105,7 +110,6 @@ function form_init_date_js() {
             'december'          => date_format_string(strtotime("December 1"), '%B', $defaulttimezone)
         ));
         $PAGE->requires->yui_module($module, $function, $config);
-        $done = true;
     }
 }
 
index dd0834b..3a450b8 100644 (file)
@@ -4200,32 +4200,32 @@ class action_menu implements renderable, templatable {
 
     /**
      * An icon to use for the toggling the secondary menu (dropdown).
-     * @var actionicon
+     * @var pix_icon
      */
     public $actionicon;
 
     /**
      * Any text to use for the toggling the secondary menu (dropdown).
-     * @var menutrigger
+     * @var string
      */
     public $menutrigger = '';
 
     /**
      * Any extra classes for toggling to the secondary menu.
-     * @var triggerextraclasses
+     * @var string
      */
     public $triggerextraclasses = '';
 
     /**
      * Place the action menu before all other actions.
-     * @var prioritise
+     * @var bool
      */
     public $prioritise = false;
 
     /**
      * Constructs the action menu with the given items.
      *
-     * @param array $actions An array of actions.
+     * @param array $actions An array of actions (action_menu_link|pix_icon|string).
      */
     public function __construct(array $actions = array()) {
         static $initialised = 0;
@@ -4259,7 +4259,6 @@ class action_menu implements renderable, templatable {
      * Sets the label for the menu trigger.
      *
      * @param string $label The text
-     * @return null
      */
     public function set_action_label($label) {
         $this->actionlabel = $label;
@@ -4270,7 +4269,6 @@ class action_menu implements renderable, templatable {
      *
      * @param string $trigger The text
      * @param string $extraclasses Extra classes to style the secondary menu toggle.
-     * @return null
      */
     public function set_menu_trigger($trigger, $extraclasses = '') {
         $this->menutrigger = $trigger;
index cda6868..2e46b8b 100644 (file)
@@ -1145,7 +1145,6 @@ EOD;
         require_once($CFG->dirroot . '/calendar/lib.php');
         $record = new \stdClass();
         $record->name = 'event name';
-        $record->eventtype = 'global';
         $record->repeat = 0;
         $record->repeats = 0;
         $record->timestart = time();
@@ -1176,7 +1175,7 @@ EOD;
                 unset($record->courseid);
                 unset($record->groupid);
                 break;
-            case 'global':
+            case 'site':
                 unset($record->categoryid);
                 unset($record->courseid);
                 unset($record->groupid);
index f52ab0f..2c61603 100644 (file)
@@ -48,16 +48,16 @@ class core_analytics_analysers_testcase extends advanced_testcase {
     public function test_courses_analyser() {
         $this->resetAfterTest(true);
 
-        $course = $this->getDataGenerator()->create_course();
-        $coursecontext = \context_course::instance($course->id);
+        $course1 = $this->getDataGenerator()->create_course();
+        $coursecontext = \context_course::instance($course1->id);
 
         $target = new test_target_shortname();
         $analyser = new \core\analytics\analyser\courses(1, $target, [], [], []);
-        $analysable = new \core_analytics\course($course);
+        $analysable = new \core_analytics\course($course1);
 
-        $this->assertInstanceOf('\core_analytics\course', $analyser->get_sample_analysable($course->id));
+        $this->assertInstanceOf('\core_analytics\course', $analyser->get_sample_analysable($course1->id));
 
-        $this->assertInstanceOf('\context_course', $analyser->sample_access_context($course->id));
+        $this->assertInstanceOf('\context_course', $analyser->sample_access_context($course1->id));
 
         // Just 1 sample per course.
         $class = new ReflectionClass('\core\analytics\analyser\courses');
@@ -66,8 +66,8 @@ class core_analytics_analysers_testcase extends advanced_testcase {
         list($sampleids, $samplesdata) = $method->invoke($analyser, $analysable);
         $this->assertCount(1, $sampleids);
         $sampleid = reset($sampleids);
-        $this->assertEquals($course->id, $sampleid);
-        $this->assertEquals($course->fullname, $samplesdata[$sampleid]['course']->fullname);
+        $this->assertEquals($course1->id, $sampleid);
+        $this->assertEquals($course1->fullname, $samplesdata[$sampleid]['course']->fullname);
         $this->assertEquals($coursecontext, $samplesdata[$sampleid]['context']);
 
         // To compare it later.
@@ -75,6 +75,16 @@ class core_analytics_analysers_testcase extends advanced_testcase {
         list($sampleids, $samplesdata) = $analyser->get_samples(array($sampleid));
         $this->assertEquals($prevsampledata['context'], $samplesdata[$sampleid]['context']);
         $this->assertEquals($prevsampledata['course']->shortname, $samplesdata[$sampleid]['course']->shortname);
+
+        // Context restriction.
+        $category1 = $this->getDataGenerator()->create_category();
+        $category1context = \context_coursecat::instance($category1->id);
+        $category2 = $this->getDataGenerator()->create_category();
+        $category2context = \context_coursecat::instance($category2->id);
+        $course2 = $this->getDataGenerator()->create_course(['category' => $category1->id]);
+        $course3 = $this->getDataGenerator()->create_course(['category' => $category2->id]);
+        $this->assertCount(2, $analyser->get_analysables_iterator(false, [$category1context, $category2context]));
+
     }
 
     /**
@@ -130,24 +140,24 @@ class core_analytics_analysers_testcase extends advanced_testcase {
 
         $this->resetAfterTest(true);
 
-        $course = $this->getDataGenerator()->create_course();
-        $coursecontext = \context_course::instance($course->id);
+        $course1 = $this->getDataGenerator()->create_course();
+        $course1context = \context_course::instance($course1->id);
 
         $user1 = $this->getDataGenerator()->create_user();
         $user2 = $this->getDataGenerator()->create_user();
         $user3 = $this->getDataGenerator()->create_user();
 
         // Checking that suspended users are also included.
-        $this->getDataGenerator()->enrol_user($user1->id, $course->id, 'student');
-        $this->getDataGenerator()->enrol_user($user2->id, $course->id, 'student', 'manual', 0, 0, ENROL_USER_SUSPENDED);
-        $this->getDataGenerator()->enrol_user($user3->id, $course->id, 'editingteacher');
-        $enrol = $DB->get_record('enrol', array('courseid' => $course->id, 'enrol' => 'manual'));
+        $this->getDataGenerator()->enrol_user($user1->id, $course1->id, 'student');
+        $this->getDataGenerator()->enrol_user($user2->id, $course1->id, 'student', 'manual', 0, 0, ENROL_USER_SUSPENDED);
+        $this->getDataGenerator()->enrol_user($user3->id, $course1->id, 'editingteacher');
+        $enrol = $DB->get_record('enrol', array('courseid' => $course1->id, 'enrol' => 'manual'));
         $ue1 = $DB->get_record('user_enrolments', array('userid' => $user1->id, 'enrolid' => $enrol->id));
         $ue2 = $DB->get_record('user_enrolments', array('userid' => $user2->id, 'enrolid' => $enrol->id));
 
         $target = new test_target_shortname();
         $analyser = new \core\analytics\analyser\student_enrolments(1, $target, [], [], []);
-        $analysable = new \core_analytics\course($course);
+        $analysable = new \core_analytics\course($course1);
 
         $this->assertInstanceOf('\core_analytics\course', $analyser->get_sample_analysable($ue1->id));
         $this->assertInstanceOf('\context_course', $analyser->sample_access_context($ue1->id));
@@ -165,8 +175,8 @@ class core_analytics_analysers_testcase extends advanced_testcase {
         // Shouldn't matter which one we select.
         $sampleid = $ue1->id;
         $this->assertEquals($ue1, $samplesdata[$sampleid]['user_enrolments']);
-        $this->assertEquals($course->fullname, $samplesdata[$sampleid]['course']->fullname);
-        $this->assertEquals($coursecontext, $samplesdata[$sampleid]['context']);
+        $this->assertEquals($course1->fullname, $samplesdata[$sampleid]['course']->fullname);
+        $this->assertEquals($course1context, $samplesdata[$sampleid]['context']);
         $this->assertEquals($user1->firstname, $samplesdata[$sampleid]['user']->firstname);
 
         // To compare it later.
@@ -176,6 +186,15 @@ class core_analytics_analysers_testcase extends advanced_testcase {
         $this->assertEquals($prevsampledata['context'], $samplesdata[$sampleid]['context']);
         $this->assertEquals($prevsampledata['course']->shortname, $samplesdata[$sampleid]['course']->shortname);
         $this->assertEquals($prevsampledata['user']->firstname, $samplesdata[$sampleid]['user']->firstname);
+
+        // Context restriction.
+        $category1 = $this->getDataGenerator()->create_category();
+        $category1context = \context_coursecat::instance($category1->id);
+        $category2 = $this->getDataGenerator()->create_category();
+        $category2context = \context_coursecat::instance($category2->id);
+        $course2 = $this->getDataGenerator()->create_course(['category' => $category1->id]);
+        $course3 = $this->getDataGenerator()->create_course(['category' => $category2->id]);
+        $this->assertCount(2, $analyser->get_analysables_iterator(false, [$category1context, $category2context]));
     }
 
     /**
index b1c9806..57a2a7a 100644 (file)
@@ -765,7 +765,7 @@ class behat_general extends behat_base {
      * @Then :preelement :preselectortype should appear before :postelement :postselectortype in the :containerelement :containerselectortype
      * @throws ExpectationException
      * @param string $preelement The locator of the preceding element
-     * @param string $preselectortype The locator of the preceding element
+     * @param string $preselectortype The selector type of the preceding element
      * @param string $postelement The locator of the latest element
      * @param string $postselectortype The selector type of the latest element
      * @param string $containerelement
@@ -779,7 +779,7 @@ class behat_general extends behat_base {
         ?string $containerelement = null,
         ?string $containerselectortype = null
     ) {
-        $msg = "'{$preelement}' '{$preselectortype}' does not appear after '{$postelement}' '{$postselectortype}'";
+        $msg = "'{$preelement}' '{$preselectortype}' does not appear before '{$postelement}' '{$postselectortype}'";
         $this->check_element_order(
             $containerelement,
             $containerselectortype,
@@ -800,7 +800,7 @@ class behat_general extends behat_base {
      * @param string $postelement The locator of the latest element
      * @param string $postselectortype The selector type of the latest element
      * @param string $preelement The locator of the preceding element
-     * @param string $preselectortype The locator of the preceding element
+     * @param string $preselectortype The selector type of the preceding element
      * @param string $containerelement
      * @param string $containerselectortype
      */
diff --git a/lib/tests/core_media_player_native.php b/lib/tests/core_media_player_native.php
new file mode 100644 (file)
index 0000000..c7e925b
--- /dev/null
@@ -0,0 +1,163 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Test for core_media_player_native.
+ *
+ * @package   core
+ * @category  test
+ * @copyright 2019 Ruslan Kabalin
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+require_once(__DIR__ . '/fixtures/testable_core_media_player_native.php');
+
+/**
+ * Test for core_media_player_native.
+ *
+ * @package   core
+ * @category  test
+ * @covers    core_media_player_native
+ * @copyright 2019 Ruslan Kabalin
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class core_media_player_native_testcase extends advanced_testcase {
+
+    /**
+     * Pre-test setup.
+     */
+    public function setUp() {
+        parent::setUp();
+        $this->resetAfterTest();
+    }
+
+    /**
+     * Test method get_supported_extensions
+     */
+    public function test_get_supported_extensions() {
+        global $CFG;
+        require_once($CFG->libdir . '/filelib.php');
+        $nativeextensions = file_get_typegroup('extension', ['html_video', 'html_audio']);
+
+        // Make sure that the list of extensions from the setting is exactly the same.
+        $player = new media_test_native_plugin();
+        $this->assertEmpty(array_diff($player->get_supported_extensions(), $nativeextensions));
+        $this->assertEmpty(array_diff($nativeextensions, $player->get_supported_extensions()));
+
+    }
+
+    /**
+     * Test method list_supported_urls
+     */
+    public function test_list_supported_urls() {
+        global $CFG;
+        require_once($CFG->libdir . '/filelib.php');
+        $nativeextensions = file_get_typegroup('extension', ['html_video', 'html_audio']);
+
+        // Create list of URLs for each extension.
+        $urls = array_map(function($ext){
+            return new moodle_url('http://example.org/video.' . $ext);
+        }, $nativeextensions);
+
+        // Make sure that the list of supported URLs is not filtering permitted extensions.
+        $player = new media_test_native_plugin();
+        $this->assertCount(count($urls), $player->list_supported_urls($urls));
+    }
+
+    /**
+     * Test method get_attribute
+     */
+    public function test_get_attribute() {
+        $urls = [
+            new moodle_url('http://example.org/some_filename.mp4'),
+            new moodle_url('http://example.org/some_filename_hires.mp4'),
+        ];
+
+        $player = new media_test_native_plugin();
+        // We are using fixture embed method directly as content generator.
+        $title = 'Some Filename Video';
+        $content = $player->embed($urls, $title, 0, 0, []);
+
+        $this->assertRegExp('~title="' . $title . '"~', $content);
+        $this->assertEquals($title, media_test_native_plugin::get_attribute($content, 'title'));
+    }
+
+    /**
+     * Test methods add_attributes and remove_attributes
+     */
+    public function test_add_remove_attributes() {
+        $urls = [
+            new moodle_url('http://example.org/some_filename.mp4'),
+            new moodle_url('http://example.org/some_filename_hires.mp4'),
+        ];
+
+        $player = new media_test_native_plugin();
+        // We are using fixture embed method directly as content generator.
+        $title = 'Some Filename Video';
+        $content = $player->embed($urls, $title, 0, 0, []);
+
+        // Add attributes.
+        $content = media_test_native_plugin::add_attributes($content, ['preload' => 'none', 'controls' => 'true']);
+        $this->assertRegExp('~title="' . $title . '"~', $content);
+        $this->assertRegExp('~preload="none"~', $content);
+        $this->assertRegExp('~controls="true"~', $content);
+
+        // Change existing attribute.
+        $content = media_test_native_plugin::add_attributes($content, ['controls' => 'false']);
+        $this->assertRegExp('~title="' . $title . '"~', $content);
+        $this->assertRegExp('~preload="none"~', $content);
+        $this->assertRegExp('~controls="false"~', $content);
+
+        // Remove attributes.
+        $content = media_test_native_plugin::remove_attributes($content, ['title']);
+        $this->assertNotRegExp('~title="' . $title . '"~', $content);
+        $this->assertRegExp('~preload="none"~', $content);
+        $this->assertRegExp('~controls="false"~', $content);
+
+        // Remove another one.
+        $content = media_test_native_plugin::remove_attributes($content, ['preload']);
+        $this->assertNotRegExp('~title="' . $title . '"~', $content);
+        $this->assertNotRegExp('~preload="none"~', $content);
+        $this->assertRegExp('~controls="false"~', $content);
+    }
+
+    /**
+     * Test method replace_sources
+     */
+    public function test_replace_sources() {
+        $urls = [
+            new moodle_url('http://example.org/some_filename.mp4'),
+            new moodle_url('http://example.org/some_filename_hires.mp4'),
+        ];
+
+        $player = new media_test_native_plugin();
+        // We are using fixture embed method directly as content generator.
+        $title = 'Some Filename Video';
+        $content = $player->embed($urls, $title, 0, 0, []);
+
+        // Test sources present.
+        $this->assertContains('<source src="http://example.org/some_filename.mp4" />', $content);
+        $this->assertContains('<source src="http://example.org/some_filename_hires.mp4" />', $content);
+
+        // Change sources.
+        $newsource = '<source src="http://example.org/new_filename.mp4" />';
+        $content = media_test_native_plugin::replace_sources($content, $newsource);
+        $this->assertContains($newsource, $content);
+        $this->assertNotContains('<source src="http://example.org/some_filename.mp4" />', $content);
+        $this->assertNotContains('<source src="http://example.org/some_filename_hires.mp4" />', $content);
+    }
+}
\ No newline at end of file
diff --git a/lib/tests/fixtures/testable_core_media_player.php b/lib/tests/fixtures/testable_core_media_player.php
new file mode 100644 (file)
index 0000000..f36b5c9
--- /dev/null
@@ -0,0 +1,90 @@
+<?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/>.
+
+/**
+ * Fixture for testing the functionality of core_media_player.
+ *
+ * @package     core
+ * @subpackage  fixtures
+ * @category    test
+ * @copyright   2012 The Open University
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Media player stub for testing purposes.
+ *
+ * @copyright   2012 The Open University
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class media_test_plugin extends core_media_player {
+    /** @var array Array of supported extensions */
+    public $ext;
+    /** @var int Player rank */
+    public $rank;
+    /** @var int Arbitrary number */
+    public $num;
+
+    /**
+     * Constructor is used for tuning the fixture.
+     *
+     * @param int $num Number (used in output)
+     * @param int $rank Player rank
+     * @param array $ext Array of supported extensions
+     */
+    public function __construct($num = 1, $rank = 13, $ext = array('mp3', 'flv', 'f4v', 'mp4')) {
+        $this->ext = $ext;
+        $this->rank = $rank;
+        $this->num = $num;
+    }
+
+    /**
+     * Generates code required to embed the player.
+     *
+     * @param array $urls URLs of media files
+     * @param string $name Display name; '' to use default
+     * @param int $width Optional width; 0 to use default
+     * @param int $height Optional height; 0 to use default
+     * @param array $options Options array
+     * @return string HTML code for embed
+     */
+    public function embed($urls, $name, $width, $height, $options) {
+        self::pick_video_size($width, $height);
+        $contents = "\ntestsource=". join("\ntestsource=", $urls) .
+            "\ntestname=$name\ntestwidth=$width\ntestheight=$height\n<!--FALLBACK-->\n";
+        return html_writer::span($contents, 'mediaplugin mediaplugin_test');
+    }
+
+    /**
+     * Gets the list of file extensions supported by this media player.
+     *
+     * @return array Array of strings (extension not including dot e.g. '.mp3')
+     */
+    public function get_supported_extensions() {
+        return $this->ext;
+    }
+
+    /**
+     * Gets the ranking of this player.
+     *
+     * @return int Rank
+     */
+    public function get_rank() {
+        return 10;
+    }
+}
\ No newline at end of file
diff --git a/lib/tests/fixtures/testable_core_media_player_native.php b/lib/tests/fixtures/testable_core_media_player_native.php
new file mode 100644 (file)
index 0000000..f484c77
--- /dev/null
@@ -0,0 +1,89 @@
+<?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/>.
+
+/**
+ * Fixture for testing the functionality of core_media_player_native.
+ *
+ * @package     core
+ * @subpackage  fixtures
+ * @category    test
+ * @copyright   2019 Ruslan Kabalin
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Native media player stub for testing purposes.
+ *
+ * @copyright   2019 Ruslan Kabalin
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class media_test_native_plugin extends core_media_player_native {
+    /** @var int Player rank */
+    public $rank;
+    /** @var int Arbitrary number */
+    public $num;
+
+    /**
+     * Constructor is used for tuning the fixture.
+     *
+     * @param int $num Number (used in output)
+     * @param int $rank Player rank
+     */
+    public function __construct($num = 1, $rank = 13) {
+        $this->rank = $rank;
+        $this->num = $num;
+    }
+
+    /**
+     * Generates code required to embed the player.
+     *
+     * @param array $urls URLs of media files
+     * @param string $name Display name; '' to use default
+     * @param int $width Optional width; 0 to use default
+     * @param int $height Optional height; 0 to use default
+     * @param array $options Options array
+     * @return string HTML code for embed
+     */
+    public function embed($urls, $name, $width, $height, $options) {
+        $sources = array();
+        foreach ($urls as $url) {
+            $params = ['src' => $url];
+            $sources[] = html_writer::empty_tag('source', $params);
+        }
+
+        $sources = implode("\n", $sources);
+        $title = $this->get_name($name, $urls);
+        // Escape title but prevent double escaping.
+        $title = s(preg_replace(['/&amp;/', '/&gt;/', '/&lt;/'], ['&', '>', '<'], $title));
+
+        return <<<OET
+<video class="mediaplugin mediaplugin_test" title="$title">
+    $sources
+</video>
+OET;
+    }
+
+    /**
+     * Gets the ranking of this player.
+     *
+     * @return int Rank
+     */
+    public function get_rank() {
+        return 10;
+    }
+}
\ No newline at end of file
index 194cc19..8b230c9 100644 (file)
 /**
  * Test classes for handling embedded media (audio/video).
  *
- * @package core_media
- * @category phpunit
+ * @package   core
+ * @category  test
  * @copyright 2012 The Open University
  * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
 
 defined('MOODLE_INTERNAL') || die();
+require_once(__DIR__ . '/fixtures/testable_core_media_player.php');
 
 /**
  * Test script for media embedding.
@@ -495,42 +496,4 @@ class core_medialib_testcase extends advanced_testcase {
         }
         return $out;
     }
-}
-
-/**
- * Media player stub for testing purposes.
- */
-class media_test_plugin extends core_media_player {
-    /** @var array Array of supported extensions */
-    public $ext;
-    /** @var int Player rank */
-    public $rank;
-    /** @var int Arbitrary number */
-    public $num;
-
-    /**
-     * @param int $num Number (used in output)
-     * @param int $rank Player rank
-     * @param array $ext Array of supported extensions
-     */
-    public function __construct($num = 1, $rank = 13, $ext = array('mp3', 'flv', 'f4v', 'mp4')) {
-        $this->ext = $ext;
-        $this->rank = $rank;
-        $this->num = $num;
-    }
-
-    public function embed($urls, $name, $width, $height, $options) {
-        self::pick_video_size($width, $height);
-        $contents = "\ntestsource=". join("\ntestsource=", $urls) .
-            "\ntestname=$name\ntestwidth=$width\ntestheight=$height\n<!--FALLBACK-->\n";
-        return html_writer::span($contents, 'mediaplugin mediaplugin_test');
-    }
-
-    public function get_supported_extensions() {
-        return $this->ext;
-    }
-
-    public function get_rank() {
-        return 10;
-    }
-}
+}
\ No newline at end of file
index f5ce3e9..66840eb 100644 (file)
@@ -29,6 +29,7 @@ require_once($CFG->dirroot.'/user/lib.php');
 require_once('change_password_form.php');
 require_once($CFG->libdir.'/authlib.php');
 require_once($CFG->dirroot.'/webservice/lib.php');
+require_once('lib.php');
 
 $id     = optional_param('id', SITEID, PARAM_INT); // current course
 $return = optional_param('return', 0, PARAM_BOOL); // redirect after password change
@@ -133,6 +134,9 @@ if ($mform->is_cancelled()) {
 
     $strpasswordchanged = get_string('passwordchanged');
 
+    // Plugins can perform post password change actions once data has been validated.
+    core_login_post_change_password_requests($data);
+
     $fullname = fullname($USER, true);
 
     $PAGE->set_title($strpasswordchanged);
index 9f2b77d..d1c978a 100644 (file)
@@ -28,6 +28,7 @@ defined('MOODLE_INTERNAL') || die();
 
 require_once($CFG->libdir.'/formslib.php');
 require_once($CFG->dirroot.'/user/lib.php');
+require_once('lib.php');
 
 class login_change_password_form extends moodleform {
 
@@ -75,6 +76,9 @@ class login_change_password_form extends moodleform {
         $mform->addElement('hidden', 'id', 0);
         $mform->setType('id', PARAM_INT);
 
+        // Hook for plugins to extend form definition.
+        core_login_extend_change_password_form($mform, $USER);
+
         // buttons
         if (get_user_preferences('auth_forcepasswordchange')) {
             $this->add_action_buttons(false);
@@ -89,6 +93,9 @@ class login_change_password_form extends moodleform {
         $errors = parent::validation($data, $files);
         $reason = null;
 
+        // Extend validation for any form extensions from plugins.
+        $errors = array_merge($errors, core_login_validate_extend_change_password_form($data, $USER));
+
         // ignore submitted username
         if (!$user = authenticate_user_login($USER->username, $data['password'], true, $reason, false)) {
             $errors['password'] = get_string('invalidlogin');
index 67a4d9f..4ad2447 100644 (file)
@@ -26,6 +26,7 @@ defined('MOODLE_INTERNAL') || die();
 
 require_once($CFG->libdir.'/formslib.php');
 require_once($CFG->dirroot.'/user/lib.php');
+require_once('lib.php');
 
 /**
  * Reset forgotten password form definition.
@@ -46,6 +47,9 @@ class login_forgot_password_form extends moodleform {
         $mform    = $this->_form;
         $mform->setDisableShortforms(true);
 
+        // Hook for plugins to extend form definition.
+        core_login_extend_forgot_password_form($mform);
+
         $mform->addElement('header', 'searchbyusername', get_string('searchbyusername'), '');
 
         $purpose = user_edit_map_field_purpose($USER->id, 'username');
@@ -74,6 +78,10 @@ class login_forgot_password_form extends moodleform {
     function validation($data, $files) {
 
         $errors = parent::validation($data, $files);
+
+        // Extend validation for any form extensions from plugins.
+        $errors = array_merge($errors, core_login_validate_extend_forgot_password_form($data));
+
         $errors += core_login_validate_forgot_password_data($data);
 
         return $errors;
index 4264333..b391ea7 100644 (file)
@@ -51,6 +51,9 @@ function core_login_process_password_reset_request() {
         }
         list($status, $notice, $url) = core_login_process_password_reset($username, $email);
 
+        // Plugins can perform post forgot password actions once data has been validated.
+        core_login_post_forgot_password_requests($data);
+
         // Any email has now been sent.
         // Next display results to requesting user if settings permit.
         echo $OUTPUT->header();
@@ -283,6 +286,10 @@ function core_login_process_password_set($token) {
 
         $urltogo = core_login_get_return_url();
         unset($SESSION->wantsurl);
+
+        // Plugins can perform post set password actions once data has been validated.
+        core_login_post_set_password_requests($data, $user);
+
         redirect($urltogo, get_string('passwordset'), 1);
     }
 }
@@ -399,3 +406,181 @@ function core_login_pre_signup_requests() {
         }
     }
 }
+
+/**
+ * Plugins can extend forms.
+ */
+
+ /** Inject form elements into change_password_form.
+  * @param mform $mform the form to inject elements into.
+  * @param stdClass $user the user object to use for context.
+  */
+function core_login_extend_change_password_form($mform, $user) {
+    $callbacks = get_plugins_with_function('extend_change_password_form');
+    foreach ($callbacks as $type => $plugins) {
+        foreach ($plugins as $plugin => $pluginfunction) {
+            $pluginfunction($mform, $user);
+        }
+    }
+}
+
+ /** Inject form elements into set_password_form.
+  * @param mform $mform the form to inject elements into.
+  * @param stdClass $user the user object to use for context.
+  */
+function core_login_extend_set_password_form($mform, $user) {
+    $callbacks = get_plugins_with_function('extend_set_password_form');
+    foreach ($callbacks as $type => $plugins) {
+        foreach ($plugins as $plugin => $pluginfunction) {
+            $pluginfunction($mform, $user);
+        }
+    }
+}
+
+ /** Inject form elements into forgot_password_form.
+  * @param mform $mform the form to inject elements into.
+  */
+function core_login_extend_forgot_password_form($mform) {
+    $callbacks = get_plugins_with_function('extend_forgot_password_form');
+    foreach ($callbacks as $type => $plugins) {
+        foreach ($plugins as $plugin => $pluginfunction) {
+            $pluginfunction($mform);
+        }
+    }
+}
+
+ /** Inject form elements into signup_form.
+  * @param mform $mform the form to inject elements into.
+  */
+function core_login_extend_signup_form($mform) {
+    $callbacks = get_plugins_with_function('extend_signup_form');
+    foreach ($callbacks as $type => $plugins) {
+        foreach ($plugins as $plugin => $pluginfunction) {
+            $pluginfunction($mform);
+        }
+    }
+}
+
+/**
+ * Plugins can add additional validation to forms.
+ */
+
+/** Inject validation into change_password_form.
+ * @param array $data the data array from submitted form values.
+ * @param stdClass $user the user object to use for context.
+ * @return array $errors the updated array of errors from validation.
+ */
+function core_login_validate_extend_change_password_form($data, $user) {
+    $pluginsfunction = get_plugins_with_function('validate_extend_change_password_form');
+    $errors = array();
+    foreach ($pluginsfunction as $plugintype => $plugins) {
+        foreach ($plugins as $pluginfunction) {
+            $pluginerrors = $pluginfunction($data, $user);
+            $errors = array_merge($errors, $pluginerrors);
+        }
+    }
+    return $errors;
+}
+
+/** Inject validation into set_password_form.
+ * @param array $data the data array from submitted form values.
+ * @param stdClass $user the user object to use for context.
+ * @return array $errors the updated array of errors from validation.
+ */
+function core_login_validate_extend_set_password_form($data, $user) {
+    $pluginsfunction = get_plugins_with_function('validate_extend_set_password_form');
+    $errors = array();
+    foreach ($pluginsfunction as $plugintype => $plugins) {
+        foreach ($plugins as $pluginfunction) {
+            $pluginerrors = $pluginfunction($data, $user);
+            $errors = array_merge($errors, $pluginerrors);
+        }
+    }
+    return $errors;
+}
+
+/** Inject validation into forgot_password_form.
+ * @param array $data the data array from submitted form values.
+ * @return array $errors the updated array of errors from validation.
+ */
+function core_login_validate_extend_forgot_password_form($data) {
+    $pluginsfunction = get_plugins_with_function('validate_extend_forgot_password_form');
+    $errors = array();
+    foreach ($pluginsfunction as $plugintype => $plugins) {
+        foreach ($plugins as $pluginfunction) {
+            $pluginerrors = $pluginfunction($data);
+            $errors = array_merge($errors, $pluginerrors);
+        }
+    }
+    return $errors;
+}
+
+/** Inject validation into signup_form.
+ * @param array $data the data array from submitted form values.
+ * @return array $errors the updated array of errors from validation.
+ */
+function core_login_validate_extend_signup_form($data) {
+    $pluginsfunction = get_plugins_with_function('validate_extend_signup_form');
+    $errors = array();
+    foreach ($pluginsfunction as $plugintype => $plugins) {
+        foreach ($plugins as $pluginfunction) {
+            $pluginerrors = $pluginfunction($data);
+            $errors = array_merge($errors, $pluginerrors);
+        }
+    }
+    return $errors;
+}
+
+/**
+ * Plugins can perform post submission actions.
+ */
+
+/** Post change_password_form submission actions.
+ * @param stdClass $data the data object from the submitted form.
+ */
+function core_login_post_change_password_requests($data) {
+    $pluginsfunction = get_plugins_with_function('post_change_password_requests');
+    foreach ($pluginsfunction as $plugintype => $plugins) {
+        foreach ($plugins as $pluginfunction) {
+            $pluginfunction($data);
+        }
+    }
+}
+
+/** Post set_password_form submission actions.
+ * @param stdClass $data the data object from the submitted form.
+ * @param stdClass $user the user object for set_password context.
+ */
+function core_login_post_set_password_requests($data, $user) {
+    $pluginsfunction = get_plugins_with_function('post_set_password_requests');
+    foreach ($pluginsfunction as $plugintype => $plugins) {
+        foreach ($plugins as $pluginfunction) {
+            $pluginfunction($data, $user);
+        }
+    }
+}
+
+/** Post forgot_password_form submission actions.
+ * @param stdClass $data the data object from the submitted form.
+ */
+function core_login_post_forgot_password_requests($data) {
+    $pluginsfunction = get_plugins_with_function('post_forgot_password_requests');
+    foreach ($pluginsfunction as $plugintype => $plugins) {
+        foreach ($plugins as $pluginfunction) {
+            $pluginfunction($data);
+        }
+    }
+}
+
+/** Post signup_form submission actions.
+ * @param stdClass $data the data object from the submitted form.
+ */
+function core_login_post_signup_requests($data) {
+    $pluginsfunction = get_plugins_with_function('post_signup_requests');
+    foreach ($pluginsfunction as $plugintype => $plugins) {
+        foreach ($plugins as $pluginfunction) {
+            $pluginfunction($data);
+        }
+    }
+}
+
index 6cee4e9..1b29fb5 100644 (file)
@@ -27,6 +27,7 @@ defined('MOODLE_INTERNAL') || die();
 
 require_once($CFG->libdir.'/formslib.php');
 require_once($CFG->dirroot.'/user/lib.php');
+require_once('lib.php');
 
 /**
  * Set forgotten password form definition.
@@ -78,6 +79,10 @@ class login_set_password_form extends moodleform {
         $mform->addRule('password2', get_string('required'), 'required', null, 'client');
         $mform->setType('password2', PARAM_RAW);
 
+        // Hook for plugins to extend form definition.
+        $user = $this->_customdata;
+        core_login_extend_set_password_form($mform, $user);
+
         $this->add_action_buttons(true);
     }
 
@@ -92,6 +97,9 @@ class login_set_password_form extends moodleform {
 
         $errors = parent::validation($data, $files);
 
+        // Extend validation for any form extensions from plugins.
+        $errors = array_merge($errors, core_login_validate_extend_set_password_form($data, $user));
+
         // Ignore submitted username.
         if ($data['password'] !== $data['password2']) {
             $errors['password'] = get_string('passwordsdiffer');
index dd2e2ce..d46f453 100644 (file)
@@ -86,6 +86,9 @@ if ($mform_signup->is_cancelled()) {
     // Add missing required fields.
     $user = signup_setup_new_user($user);
 
+    // Plugins can perform post sign up actions once data has been validated.
+    core_login_post_signup_requests($user);
+
     $authplugin->user_signup($user, true); // prints notice and link to login/index.php
     exit; //never reached
 }
index ca975aa..8dc5354 100644 (file)
@@ -28,6 +28,7 @@ defined('MOODLE_INTERNAL') || die();
 require_once($CFG->libdir.'/formslib.php');
 require_once($CFG->dirroot.'/user/profile/lib.php');
 require_once($CFG->dirroot . '/user/editlib.php');
+require_once('lib.php');
 
 class login_signup_form extends moodleform implements renderable, templatable {
     function definition() {
@@ -97,6 +98,9 @@ class login_signup_form extends moodleform implements renderable, templatable {
             $mform->closeHeaderBefore('recaptcha_element');
         }
 
+        // Hook for plugins to extend form definition.
+        core_login_extend_signup_form($mform);
+
         // Add "Agree to sitepolicy" controls. By default it is a link to the policy text and a checkbox but
         // it can be implemented differently in custom sitepolicy handlers.
         $manager = new \core_privacy\local\sitepolicy\manager();
@@ -128,6 +132,9 @@ class login_signup_form extends moodleform implements renderable, templatable {
     public function validation($data, $files) {
         $errors = parent::validation($data, $files);
 
+        // Extend validation for any form extensions from plugins.
+        $errors = array_merge($errors, core_login_validate_extend_signup_form($data));
+
         if (signup_captcha_enabled()) {
             $recaptchaelement = $this->_form->getElement('recaptcha_element');
             if (!empty($this->_form->_submitValues['g-recaptcha-response'])) {
index 0c243c2..7dfe8e4 100644 (file)
@@ -60,7 +60,7 @@ class media_html5audio_testcase extends advanced_testcase {
     /**
      * Test method get_supported_extensions()
      */
-    public function test_supported_extensions() {
+    public function test_get_supported_extensions() {
         global $CFG;
         require_once($CFG->libdir . '/filelib.php');
 
@@ -72,6 +72,25 @@ class media_html5audio_testcase extends advanced_testcase {
         $this->assertEmpty(array_diff($nativeextensions, $player->get_supported_extensions()));
     }
 
+    /**
+     * Test method list_supported_urls()
+     */
+    public function test_list_supported_urls() {
+        global $CFG;
+        require_once($CFG->libdir . '/filelib.php');
+
+        $nativeextensions = file_get_typegroup('extension', 'html_audio');
+
+        // Create list of URLs for each extension.
+        $urls = array_map(function($ext){
+            return new moodle_url('http://example.org/audio.' . $ext);
+        }, $nativeextensions);
+
+        // Make sure that the list of supported URLs is not filtering permitted extensions.
+        $player = new media_html5audio_plugin();
+        $this->assertCount(count($urls), $player->list_supported_urls($urls));
+    }
+
     /**
      * Test embedding without media filter (for example for displaying file resorce).
      */
index 7caf405..3def9af 100644 (file)
@@ -60,7 +60,7 @@ class media_html5video_testcase extends advanced_testcase {
     /**
      * Test method get_supported_extensions()
      */
-    public function test_supported_extensions() {
+    public function test_get_supported_extensions() {
         $nativeextensions = file_get_typegroup('extension', 'html_video');
 
         // Make sure that the list of extensions from the setting is exactly the same as html_video group.
@@ -69,6 +69,25 @@ class media_html5video_testcase extends advanced_testcase {
         $this->assertEmpty(array_diff($nativeextensions, $player->get_supported_extensions()));
     }
 
+    /**
+     * Test method list_supported_urls()
+     */
+    public function test_list_supported_urls() {
+        global $CFG;
+        require_once($CFG->libdir . '/filelib.php');
+
+        $nativeextensions = file_get_typegroup('extension', 'html_video');
+
+        // Create list of URLs for each extension.
+        $urls = array_map(function($ext){
+            return new moodle_url('http://example.org/video.' . $ext);
+        }, $nativeextensions);
+
+        // Make sure that the list of supported URLs is not filtering permitted extensions.
+        $player = new media_html5video_plugin();
+        $this->assertCount(count($urls), $player->list_supported_urls($urls));
+    }
+
     /**
      * Test embedding without media filter (for example for displaying file resorce).
      */
index f95afe3..5271dc1 100644 (file)
Binary files a/message/amd/build/message_drawer_view_overview_section.min.js and b/message/amd/build/message_drawer_view_overview_section.min.js differ
index 9f43297..32de90f 100644 (file)
Binary files a/message/amd/build/message_drawer_view_overview_section.min.js.map and b/message/amd/build/message_drawer_view_overview_section.min.js.map differ
index e708a44..8fa7398 100644 (file)
@@ -212,7 +212,11 @@ function(
             if (!lastMessage) {
                 return null;
             }
-            var isMedia = lastMessage.text.includes('src');
+            // Check the message html for a src attribute, indicative of media.
+            // Replace <img with <noimg to stop browsers pre-fetching the image as part of tmp element creation.
+            var tmpElement = document.createElement("element");
+            tmpElement.innerHTML = lastMessage.text.replace(/<img /g, '<noimg ');
+            var isMedia = tmpElement.querySelector("[src]") || false;
 
             if (!isMedia) {
                 // Try to get the text value of the content.
index b6cb9ad..155064a 100644 (file)
  */
 
 namespace assignfeedback_editpdf;
+use setasign\Fpdi\TcpdfFpdi;
 
 defined('MOODLE_INTERNAL') || die();
 
 global $CFG;
 require_once($CFG->libdir.'/pdflib.php');
-require_once($CFG->dirroot.'/mod/assign/feedback/editpdf/fpdi/fpdi.php');
+require_once($CFG->dirroot.'/mod/assign/feedback/editpdf/fpdi/autoload.php');
 
 /**
  * Library code for manipulating PDFs
@@ -37,7 +38,7 @@ require_once($CFG->dirroot.'/mod/assign/feedback/editpdf/fpdi/fpdi.php');
  * @copyright 2012 Davo Smith
  * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
-class pdf extends \FPDI {
+class pdf extends TcpdfFpdi {
 
     /** @var int the number of the current page in the PDF being processed */
     protected $currentpage = 0;
@@ -209,14 +210,11 @@ class pdf extends \FPDI {
         // Get the size (and deduce the orientation) of the next page.
         $template = $this->importPage($pageno);
         $size = $this->getTemplateSize($template);
-        $orientation = 'P';
-        if ($size['w'] > $size['h']) {
-            $orientation = 'L';
-        }
+
         // Create a page of the required size / orientation.
-        $this->AddPage($orientation, array($size['w'], $size['h']));
+        $this->AddPage($size['orientation'], array($size['width'], $size['height']));
         // Prevent new page creation when comments are at the bottom of a page.
-        $this->setPageOrientation($orientation, false, 0);
+        $this->setPageOrientation($size['orientation'], false, 0);
         // Fill in the page with the original contents from the student.
         $this->useTemplate($template);
     }
@@ -780,25 +778,23 @@ class pdf extends \FPDI {
         $imagecontent = $imagestoredfile->get_content();
         $this->currentpage++;
         $template = $this->importPage($this->currentpage);
-        $size = $this->getTemplateSize($template);
+        // Defaulting dimensions to 0 because as of 2.x the default has changed to NULL.
+        $size = $this->getTemplateSize($template, 0, 0);
 
         if ($imageinfo["width"] > $imageinfo["height"]) {
-            if ($size['w'] < $size['h']) {
-                $temp = $size['w'];
-                $size['w'] = $size['h'];
-                $size['h'] = $temp;
+            if ($size['width'] < $size['height']) {
+                $temp = $size['width'];
+                $size['width'] = $size['height'];
+                $size['height'] = $temp;
             }
-            $orientation = 'L';
         } else if ($imageinfo["width"] < $imageinfo["height"]) {
-            if ($size['w'] > $size['h']) {
-                $temp = $size['w'];
-                $size['w'] = $size['h'];
-                $size['h'] = $temp;
+            if ($size['width'] > $size['height']) {
+                $temp = $size['width'];
+                $size['width'] = $size['height'];
+                $size['height'] = $temp;
             }
-            $orientation = 'P';
-        } else {
-            $orientation = 'P';
         }
+        $orientation = $size['orientation'];
         $this->SetHeaderMargin(0);
         $this->SetFooterMargin(0);
         $this->SetMargins(0, 0, 0, true);
diff --git a/mod/assign/feedback/editpdf/fpdi/FpdfTpl.php b/mod/assign/feedback/editpdf/fpdi/FpdfTpl.php
new file mode 100644 (file)
index 0000000..a1bfded
--- /dev/null
@@ -0,0 +1,22 @@
+<?php
+/**
+ * This file is part of FPDI
+ *
+ * @package   setasign\Fpdi
+ * @copyright Copyright (c) 2019 Setasign - Jan Slabon (https://www.setasign.com)
+ * @license   http://opensource.org/licenses/mit-license The MIT License
+ */
+
+namespace setasign\Fpdi;
+
+/**
+ * Class FpdfTpl
+ *
+ * This class adds a templating feature to FPDF.
+ *
+ * @package setasign\Fpdi
+ */
+class FpdfTpl extends \Fpdf
+{
+    use FpdfTplTrait;
+}
diff --git a/mod/assign/feedback/editpdf/fpdi/FpdfTplTrait.php b/mod/assign/feedback/editpdf/fpdi/FpdfTplTrait.php
new file mode 100644 (file)
index 0000000..9fb0d9a
--- /dev/null
@@ -0,0 +1,466 @@
+<?php
+/**
+ * This file is part of FPDI
+ *
+ * @package   setasign\Fpdi
+ * @copyright Copyright (c) 2019 Setasign - Jan Slabon (https://www.setasign.com)
+ * @license   http://opensource.org/licenses/mit-license The MIT License
+ */
+
+namespace setasign\Fpdi;
+
+/**
+ * Trait FpdfTplTrait
+ *
+ * This class adds a templating feature to tFPDF.
+ *
+ * @package setasign\Fpdi
+ */
+trait FpdfTplTrait
+{
+    /**
+     * Data of all created templates.
+     *
+     * @var array
+     */
+    protected $templates = [];
+
+    /**
+     * The template id for the currently created template.
+     *
+     * @var null|int
+     */
+    protected $currentTemplateId;
+
+    /**
+     * A counter for template ids.
+     *
+     * @var int
+     */
+    protected $templateId = 0;
+
+    /**
+     * Set the page format of the current page.
+     *
+     * @param array $size An array with two values defining the size.
+     * @param string $orientation "L" for landscape, "P" for portrait.
+     * @throws \BadMethodCallException
+     */
+    public function setPageFormat($size, $orientation)
+    {
+        if ($this->currentTemplateId !== null) {
+            throw new \BadMethodCallException('The page format cannot be changed when writing to a template.');
+        }
+
+        if (!\in_array($orientation, ['P', 'L'], true)) {
+            throw new \InvalidArgumentException(\sprintf(
+                'Invalid page orientation "%s"! Only "P" and "L" are allowed!',
+                $orientation
+            ));
+        }
+
+        $size = $this->_getpagesize($size);
+
+        if ($orientation != $this->CurOrientation
+            || $size[0] != $this->CurPageSize[0]
+            || $size[1] != $this->CurPageSize[1]
+        ) {
+            // New size or orientation
+            if ($orientation === 'P') {
+                $this->w = $size[0];
+                $this->h = $size[1];
+            } else {
+                $this->w = $size[1];
+                $this->h = $size[0];
+            }
+            $this->wPt = $this->w * $this->k;
+            $this->hPt = $this->h * $this->k;
+            $this->PageBreakTrigger = $this->h - $this->bMargin;
+            $this->CurOrientation = $orientation;
+            $this->CurPageSize = $size;
+
+            $this->PageInfo[$this->page]['size'] = array($this->wPt, $this->hPt);
+        }
+    }
+
+    /**
+     * Draws a template onto the page or another template.
+     *
+     * Give only one of the size parameters (width, height) to calculate the other one automatically in view to the
+     * aspect ratio.
+     *
+     * @param mixed $tpl The template id
+     * @param array|float|int $x The abscissa of upper-left corner. Alternatively you could use an assoc array
+     *                           with the keys "x", "y", "width", "height", "adjustPageSize".
+     * @param float|int $y The ordinate of upper-left corner.
+     * @param float|int|null $width The width.
+     * @param float|int|null $height The height.
+     * @param bool $adjustPageSize
+     * @return array The size
+     * @see FpdfTplTrait::getTemplateSize()
+     */
+    public function useTemplate($tpl, $x = 0, $y = 0, $width = null, $height = null, $adjustPageSize = false)
+    {
+        if (!isset($this->templates[$tpl])) {
+            throw new \InvalidArgumentException('Template does not exist!');
+        }
+
+        if (\is_array($x)) {
+            unset($x['tpl']);
+            \extract($x, EXTR_IF_EXISTS);
+            /** @noinspection NotOptimalIfConditionsInspection */
+            /** @noinspection CallableParameterUseCaseInTypeContextInspection */
+            if (\is_array($x)) {
+                $x = 0;
+            }
+        }
+
+        $template = $this->templates[$tpl];
+
+        $originalSize = $this->getTemplateSize($tpl);
+        $newSize = $this->getTemplateSize($tpl, $width, $height);
+        if ($adjustPageSize) {
+            $this->setPageFormat($newSize, $newSize['orientation']);
+        }
+
+        $this->_out(
+        // reset standard values, translate and scale
+            \sprintf(
+                'q 0 J 1 w 0 j 0 G 0 g %.4F 0 0 %.4F %.4F %.4F cm /%s Do Q',
+                ($newSize['width'] / $originalSize['width']),
+                ($newSize['height'] / $originalSize['height']),
+                $x * $this->k,
+                ($this->h - $y - $newSize['height']) * $this->k,
+                $template['id']
+            )
+        );
+
+        return $newSize;
+    }
+
+    /**
+     * Get the size of a template.
+     *
+     * Give only one of the size parameters (width, height) to calculate the other one automatically in view to the
+     * aspect ratio.
+     *
+     * @param mixed $tpl The template id
+     * @param float|int|null $width The width.
+     * @param float|int|null $height The height.
+     * @return array|bool An array with following keys: width, height, 0 (=width), 1 (=height), orientation (L or P)
+     */
+    public function getTemplateSize($tpl, $width = null, $height = null)
+    {
+        if (!isset($this->templates[$tpl])) {
+            return false;
+        }
+
+        if ($width === null && $height === null) {
+            $width = $this->templates[$tpl]['width'];
+            $height = $this->templates[$tpl]['height'];
+        } elseif ($width === null) {
+            $width = $height * $this->templates[$tpl]['width'] / $this->templates[$tpl]['height'];
+        }
+
+        if ($height === null) {
+            $height = $width * $this->templates[$tpl]['height'] / $this->templates[$tpl]['width'];
+        }
+
+        if ($height <= 0. || $width <= 0.) {
+            throw new \InvalidArgumentException('Width or height parameter needs to be larger than zero.');
+        }
+
+        return [
+            'width' => $width,
+            'height' => $height,
+            0 => $width,
+            1 => $height,
+            'orientation' => $width > $height ? 'L' : 'P'
+        ];
+    }
+
+    /**
+     * Begins a new template.
+     *
+     * @param float|int|null $width The width of the template. If null, the current page width is used.
+     * @param float|int|null $height The height of the template. If null, the current page height is used.
+     * @param bool $groupXObject Define the form XObject as a group XObject to support transparency (if used).
+     * @return int A template identifier.
+     */
+    public function beginTemplate($width = null, $height = null, $groupXObject = false)
+    {
+        if ($width === null) {
+            $width = $this->w;
+        }
+
+        if ($height === null) {
+            $height = $this->h;
+        }
+
+        $templateId = $this->getNextTemplateId();
+
+        // initiate buffer with current state of FPDF
+        $buffer = "2 J\n"
+            . \sprintf('%.2F w', $this->LineWidth * $this->k) . "\n";
+
+        if ($this->FontFamily) {
+            $buffer .= \sprintf("BT /F%d %.2F Tf ET\n", $this->CurrentFont['i'], $this->FontSizePt);
+        }
+
+        if ($this->DrawColor !== '0 G') {
+            $buffer .= $this->DrawColor . "\n";
+        }
+        if ($this->FillColor !== '0 g') {
+            $buffer .= $this->FillColor . "\n";
+        }
+
+        if ($groupXObject && \version_compare('1.4', $this->PDFVersion, '>')) {
+            $this->PDFVersion = '1.4';
+        }
+
+        $this->templates[$templateId] = [
+            'objectNumber' => null,
+            'id' => 'TPL' . $templateId,
+            'buffer' => $buffer,
+            'width' => $width,
+            'height' => $height,
+            'groupXObject' => $groupXObject,
+            'state' => [
+                'x' => $this->x,
+                'y' => $this->y,
+                'AutoPageBreak' => $this->AutoPageBreak,
+                'bMargin' => $this->bMargin,
+                'tMargin' => $this->tMargin,
+                'lMargin' => $this->lMargin,
+                'rMargin' => $this->rMargin,
+                'h' => $this->h,
+                'w' => $this->w,
+                'FontFamily' => $this->FontFamily,
+                'FontStyle' => $this->FontStyle,
+                'FontSizePt' => $this->FontSizePt,
+                'FontSize' => $this->FontSize,
+                'underline' => $this->underline,
+                'TextColor' => $this->TextColor,
+                'DrawColor' => $this->DrawColor,
+                'FillColor' => $this->FillColor,
+                'ColorFlag' => $this->ColorFlag
+            ]
+        ];
+
+        $this->SetAutoPageBreak(false);
+        $this->currentTemplateId = $templateId;
+
+        $this->h = $height;
+        $this->w = $width;
+
+        $this->SetXY($this->lMargin, $this->tMargin);
+        $this->SetRightMargin($this->w - $width + $this->rMargin);
+
+        return $templateId;
+    }
+
+    /**
+     * Ends a template.
+     *
+     * @return bool|int|null A template identifier.
+     */
+    public function endTemplate()
+    {
+        if (null === $this->currentTemplateId) {
+            return false;
+        }
+
+        $templateId = $this->currentTemplateId;
+        $template = $this->templates[$templateId];
+
+        $state = $template['state'];
+        $this->SetXY($state['x'], $state['y']);
+        $this->tMargin = $state['tMargin'];
+        $this->lMargin = $state['lMargin'];
+        $this->rMargin = $state['rMargin'];
+        $this->h = $state['h'];
+        $this->w = $state['w'];
+        $this->SetAutoPageBreak($state['AutoPageBreak'], $state['bMargin']);
+
+        $this->FontFamily = $state['FontFamily'];
+        $this->FontStyle = $state['FontStyle'];
+        $this->FontSizePt = $state['FontSizePt'];
+        $this->FontSize = $state['FontSize'];
+
+        $this->TextColor = $state['TextColor'];
+        $this->DrawColor = $state['DrawColor'];
+        $this->FillColor = $state['FillColor'];
+        $this->ColorFlag = $state['ColorFlag'];
+
+        $this->underline = $state['underline'];
+
+        $fontKey = $this->FontFamily . $this->FontStyle;
+        if ($fontKey) {
+            $this->CurrentFont =& $this->fonts[$fontKey];
+        } else {
+            unset($this->CurrentFont);
+        }
+
+        $this->currentTemplateId = null;
+
+        return $templateId;
+    }
+
+    /**
+     * Get the next template id.
+     *
+     * @return int
+     */
+    protected function getNextTemplateId()
+    {
+        return $this->templateId++;
+    }
+
+    /* overwritten FPDF methods: */
+
+    /**
+     * @inheritdoc
+     */
+    public function AddPage($orientation = '', $size = '', $rotation = 0)
+    {
+        if ($this->currentTemplateId !== null) {
+            throw new \BadMethodCallException('Pages cannot be added when writing to a template.');
+        }
+        parent::AddPage($orientation, $size, $rotation);
+    }
+
+    /**
+     * @inheritdoc
+     */
+    public function Link($x, $y, $w, $h, $link)
+    {
+        if ($this->currentTemplateId !== null) {
+            throw new \BadMethodCallException('Links cannot be set when writing to a template.');
+        }
+        parent::Link($x, $y, $w, $h, $link);
+    }
+
+    /**
+     * @inheritdoc
+     */
+    public function SetLink($link, $y = 0, $page = -1)
+    {
+        if ($this->currentTemplateId !== null) {
+            throw new \BadMethodCallException('Links cannot be set when writing to a template.');
+        }
+        return parent::SetLink($link, $y, $page);
+    }
+
+    /**
+     * @inheritdoc
+     */
+    public function SetDrawColor($r, $g = null, $b = null)
+    {
+        parent::SetDrawColor($r, $g, $b);
+        if ($this->page === 0 && $this->currentTemplateId !== null) {
+            $this->_out($this->DrawColor);
+        }
+    }
+
+    /**
+     * @inheritdoc
+     */
+    public function SetFillColor($r, $g = null, $b = null)
+    {
+        parent::SetFillColor($r, $g, $b);
+        if ($this->page === 0 && $this->currentTemplateId !== null) {
+            $this->_out($this->FillColor);
+        }
+    }
+
+    /**
+     * @inheritdoc
+     */
+    public function SetLineWidth($width)
+    {
+        parent::SetLineWidth($width);
+        if ($this->page === 0 && $this->currentTemplateId !== null) {
+            $this->_out(\sprintf('%.2F w', $width * $this->k));
+        }
+    }
+
+    /**
+     * @inheritdoc
+     */
+    public function SetFont($family, $style = '', $size = 0)
+    {
+        parent::SetFont($family, $style, $size);
+        if ($this->page === 0 && $this->currentTemplateId !== null) {
+            $this->_out(\sprintf('BT /F%d %.2F Tf ET', $this->CurrentFont['i'], $this->FontSizePt));
+        }
+    }
+
+    /**
+     * @inheritdoc
+     */
+    public function SetFontSize($size)
+    {
+        parent::SetFontSize($size);
+        if ($this->page === 0 && $this->currentTemplateId !== null) {
+            $this->_out(sprintf('BT /F%d %.2F Tf ET', $this->CurrentFont['i'], $this->FontSizePt));
+        }
+    }
+
+    /**
+     * @inheritdoc
+     */
+    protected function _putimages()
+    {
+        parent::_putimages();
+
+        foreach ($this->templates as $key => $template) {
+            $this->_newobj();
+            $this->templates[$key]['objectNumber'] = $this->n;
+
+            $this->_put('<</Type /XObject /Subtype /Form /FormType 1');
+            $this->_put(\sprintf('/BBox[0 0 %.2F %.2F]', $template['width'] * $this->k, $template['height'] * $this->k));
+            $this->_put('/Resources 2 0 R'); // default resources dictionary of FPDF
+
+            if ($this->compress) {
+                $buffer = \gzcompress($template['buffer']);
+                $this->_put('/Filter/FlateDecode');
+            } else {
+                $buffer = $template['buffer'];
+            }
+
+            $this->_put('/Length ' . \strlen($buffer));
+
+            if ($template['groupXObject']) {
+                $this->_put('/Group <</Type/Group/S/Transparency>>');
+            }
+
+            $this->_put('>>');
+            $this->_putstream($buffer);
+            $this->_put('endobj');
+        }
+    }
+
+    /**
+     * @inheritdoc
+     */
+    protected function _putxobjectdict()
+    {
+        foreach ($this->templates as $key => $template) {
+            $this->_put('/' . $template['id'] . ' ' . $template['objectNumber'] . ' 0 R');
+        }
+
+        parent::_putxobjectdict();
+    }
+
+    /**
+     * @inheritdoc
+     */
+    public function _out($s)
+    {
+        if ($this->currentTemplateId !== null) {
+            $this->templates[$this->currentTemplateId]['buffer'] .= $s . "\n";
+        } else {
+            parent::_out($s);
+        }
+    }
+}
\ No newline at end of file
diff --git a/mod/assign/feedback/editpdf/fpdi/Fpdi.php b/mod/assign/feedback/editpdf/fpdi/Fpdi.php
new file mode 100644 (file)
index 0000000..b49c103
--- /dev/null
@@ -0,0 +1,155 @@
+<?php
+/**
+ * This file is part of FPDI
+ *
+ * @package   setasign\Fpdi
+ * @copyright Copyright (c) 2019 Setasign - Jan Slabon (https://www.setasign.com)
+ * @license   http://opensource.org/licenses/mit-license The MIT License
+ */
+
+namespace setasign\Fpdi;
+
+use setasign\Fpdi\PdfParser\CrossReference\CrossReferenceException;
+use setasign\Fpdi\PdfParser\PdfParserException;
+use setasign\Fpdi\PdfParser\Type\PdfIndirectObject;
+use setasign\Fpdi\PdfParser\Type\PdfNull;
+
+/**
+ * Class Fpdi
+ *
+ * This class let you import pages of existing PDF documents into a reusable structure for FPDF.
+ *
+ * @package setasign\Fpdi
+ */
+class Fpdi extends FpdfTpl
+{
+    use FpdiTrait;
+
+    /**
+     * FPDI version
+     *
+     * @string
+     */
+    const VERSION = '2.2.0';
+
+    protected function _enddoc()
+    {
+        parent::_enddoc();
+        $this->cleanUp();
+    }
+
+    /**
+     * Draws an imported page or a template onto the page or another template.
+     *
+     * Give only one of the size parameters (width, height) to calculate the other one automatically in view to the
+     * aspect ratio.
+     *
+     * @param mixed $tpl The template id
+     * @param float|int|array $x The abscissa of upper-left corner. Alternatively you could use an assoc array
+     *                           with the keys "x", "y", "width", "height", "adjustPageSize".
+     * @param float|int $y The ordinate of upper-left corner.
+     * @param float|int|null $width The width.
+     * @param float|int|null $height The height.
+     * @param bool $adjustPageSize
+     * @return array The size
+     * @see Fpdi::getTemplateSize()
+     */
+    public function useTemplate($tpl, $x = 0, $y = 0, $width = null, $height = null, $adjustPageSize = false)
+    {
+        if (isset($this->importedPages[$tpl])) {
+            $size = $this->useImportedPage($tpl, $x, $y, $width, $height, $adjustPageSize);
+            if ($this->currentTemplateId !== null) {
+                $this->templates[$this->currentTemplateId]['resources']['templates']['importedPages'][$tpl] = $tpl;
+            }
+            return $size;
+        }
+
+        return parent::useTemplate($tpl, $x, $y, $width, $height, $adjustPageSize);
+    }
+
+    /**
+     * Get the size of an imported page or template.
+     *
+     * Give only one of the size parameters (width, height) to calculate the other one automatically in view to the
+     * aspect ratio.
+     *
+     * @param mixed $tpl The template id
+     * @param float|int|null $width The width.
+     * @param float|int|null $height The height.
+     * @return array|bool An array with following keys: width, height, 0 (=width), 1 (=height), orientation (L or P)
+     */
+    public function getTemplateSize($tpl, $width = null, $height = null)
+    {
+        $size = parent::getTemplateSize($tpl, $width, $height);
+        if ($size === false) {
+            return $this->getImportedPageSize($tpl, $width, $height);
+        }
+
+        return $size;
+    }
+
+    /**
+     * @inheritdoc
+     * @throws CrossReferenceException
+     * @throws PdfParserException
+     */
+    protected function _putimages()
+    {
+        $this->currentReaderId = null;
+        parent::_putimages();
+
+        foreach ($this->importedPages as $key => $pageData) {
+            $this->_newobj();
+            $this->importedPages[$key]['objectNumber'] = $this->n;
+            $this->currentReaderId = $pageData['readerId'];
+            $this->writePdfType($pageData['stream']);
+            $this->_put('endobj');
+        }
+
+        foreach (\array_keys($this->readers) as $readerId) {
+            $parser = $this->getPdfReader($readerId)->getParser();
+            $this->currentReaderId = $readerId;
+
+            while (($objectNumber = \array_pop($this->objectsToCopy[$readerId])) !== null) {
+                try {
+                    $object = $parser->getIndirectObject($objectNumber);
+
+                } catch (CrossReferenceException $e) {
+                    if ($e->getCode() === CrossReferenceException::OBJECT_NOT_FOUND) {
+                        $object = PdfIndirectObject::create($objectNumber, 0, new PdfNull());
+                    } else {
+                        throw $e;
+                    }
+                }
+
+                $this->writePdfType($object);
+            }
+        }
+
+        $this->currentReaderId = null;
+    }
+
+    /**
+     * @inheritdoc
+     */
+    protected function _putxobjectdict()
+    {
+        foreach ($this->importedPages as $key => $pageData) {
+            $this->_put('/' . $pageData['id'] . ' ' . $pageData['objectNumber'] . ' 0 R');
+        }
+
+        parent::_putxobjectdict();
+    }
+
+    /**
+     * @inheritdoc
+     */
+    protected function _put($s, $newLine = true)
+    {
+        if ($newLine) {
+            $this->buffer .= $s . "\n";
+        } else {
+            $this->buffer .= $s;
+        }
+    }
+}
diff --git a/mod/assign/feedback/editpdf/fpdi/FpdiException.php b/mod/assign/feedback/editpdf/fpdi/FpdiException.php
new file mode 100644 (file)
index 0000000..d126c14
--- /dev/null
@@ -0,0 +1,19 @@
+<?php
+/**
+ * This file is part of FPDI
+ *
+ * @package   setasign\Fpdi
+ * @copyright Copyright (c) 2019 Setasign - Jan Slabon (https://www.setasign.com)
+ * @license   http://opensource.org/licenses/mit-license The MIT License
+ */
+
+namespace setasign\Fpdi;
+
+/**
+ * Base exception class for the FPDI package.
+ *
+ * @package setasign\Fpdi
+ */
+class FpdiException extends \Exception
+{
+}
diff --git a/mod/assign/feedback/editpdf/fpdi/FpdiTrait.php b/mod/assign/feedback/editpdf/fpdi/FpdiTrait.php
new file mode 100644 (file)
index 0000000..0bdbc03
--- /dev/null
@@ -0,0 +1,562 @@
+<?php
+/**
+ * This file is part of FPDI
+ *
+ * @package   setasign\Fpdi
+ * @copyright Copyright (c) 2019 Setasign - Jan Slabon (https://www.setasign.com)
+ * @license   http://opensource.org/licenses/mit-license The MIT License
+ */
+
+namespace setasign\Fpdi;
+
+use setasign\Fpdi\PdfParser\CrossReference\CrossReferenceException;
+use setasign\Fpdi\PdfParser\Filter\FilterException;
+use setasign\Fpdi\PdfParser\PdfParser;
+use setasign\Fpdi\PdfParser\PdfParserException;
+use setasign\Fpdi\PdfParser\StreamReader;
+use setasign\Fpdi\PdfParser\Type\PdfArray;
+use setasign\Fpdi\PdfParser\Type\PdfBoolean;
+use setasign\Fpdi\PdfParser\Type\PdfDictionary;
+use setasign\Fpdi\PdfParser\Type\PdfHexString;
+use setasign\Fpdi\PdfParser\Type\PdfIndirectObject;
+use setasign\Fpdi\PdfParser\Type\PdfIndirectObjectReference;
+use setasign\Fpdi\PdfParser\Type\PdfName;
+use setasign\Fpdi\PdfParser\Type\PdfNull;
+use setasign\Fpdi\PdfParser\Type\PdfNumeric;
+use setasign\Fpdi\PdfParser\Type\PdfStream;
+use setasign\Fpdi\PdfParser\Type\PdfString;
+use setasign\Fpdi\PdfParser\Type\PdfToken;
+use setasign\Fpdi\PdfParser\Type\PdfType;
+use setasign\Fpdi\PdfParser\Type\PdfTypeException;
+use setasign\Fpdi\PdfReader\PageBoundaries;
+use setasign\Fpdi\PdfReader\PdfReader;
+use setasign\Fpdi\PdfReader\PdfReaderException;
+use /* This namespace/class is used by the commercial FPDI PDF-Parser add-on. */
+    /** @noinspection PhpUndefinedClassInspection */
+    /** @noinspection PhpUndefinedNamespaceInspection */
+    setasign\FpdiPdfParser\PdfParser\PdfParser as FpdiPdfParser;
+
+/**
+ * The FpdiTrait
+ *
+ * This trait offers the core functionalities of FPDI. By passing them to a trait we can reuse it with e.g. TCPDF in a
+ * very easy way.
+ *
+ * @package setasign\Fpdi
+ */
+trait FpdiTrait
+{
+    /**
+     * The pdf reader instances.
+     *
+     * @var PdfReader[]
+     */
+    protected $readers = [];
+
+    /**
+     * Instances created internally.
+     *
+     * @var array
+     */
+    protected $createdReaders = [];
+
+    /**
+     * The current reader id.
+     *
+     * @var string
+     */
+    protected $currentReaderId;
+
+    /**
+     * Data of all imported pages.
+     *
+     * @var array
+     */
+    protected $importedPages = [];
+
+    /**
+     * A map from object numbers of imported objects to new assigned object numbers by FPDF.
+     *
+     * @var array
+     */
+    protected $objectMap = [];
+
+    /**
+     * An array with information about objects, which needs to be copied to the resulting document.
+     *
+     * @var array
+     */
+    protected $objectsToCopy = [];
+
+    /**
+     * Release resources and file handles.
+     *
+     * This method is called internally when the document is created successfully. By default it only cleans up
+     * stream reader instances which were created internally.
+     *
+     * @param bool $allReaders
+     */
+    public function cleanUp($allReaders = false)
+    {
+        $readers = $allReaders ? array_keys($this->readers) : $this->createdReaders;
+        foreach ($readers as $id) {
+            $this->readers[$id]->getParser()->getStreamReader()->cleanUp();
+            unset($this->readers[$id]);
+        }
+
+        $this->createdReaders= [];
+    }
+
+    /**
+     * Set the minimal PDF version.
+     *
+     * @param string $pdfVersion
+     */
+    protected function setMinPdfVersion($pdfVersion)
+    {
+        if (\version_compare($pdfVersion, $this->PDFVersion, '>')) {
+            $this->PDFVersion = $pdfVersion;
+        }
+    }
+
+    /** @noinspection PhpUndefinedClassInspection */
+    /**
+     * Get a new pdf parser instance.
+     *
+     * @param StreamReader $streamReader
+     * @return PdfParser|FpdiPdfParser
+     */
+    protected function getPdfParserInstance(StreamReader $streamReader)
+    {
+        /** @noinspection PhpUndefinedClassInspection */
+        if (\class_exists(FpdiPdfParser::class)) {
+            /** @noinspection PhpUndefinedClassInspection */
+            return new FpdiPdfParser($streamReader);
+        }
+
+        return new PdfParser($streamReader);
+    }
+
+    /**
+     * Get an unique reader id by the $file parameter.
+     *
+     * @param string|resource|PdfReader|StreamReader $file An open file descriptor, a path to a file, a PdfReader
+     *                                                     instance or a StreamReader instance.
+     * @return string
+     */
+    protected function getPdfReaderId($file)
+    {
+        if (\is_resource($file)) {
+            $id = (string) $file;
+        } elseif (\is_string($file)) {
+            $id = \realpath($file);
+            if ($id === false) {
+                $id = $file;
+            }
+        } elseif (\is_object($file)) {
+            $id = \spl_object_hash($file);
+        } else {
+            throw new \InvalidArgumentException(
+                \sprintf('Invalid type in $file parameter (%s)', \gettype($file))
+            );
+        }
+
+        /** @noinspection OffsetOperationsInspection */
+        if (isset($this->readers[$id])) {
+            return $id;
+        }
+
+        if (\is_resource($file)) {
+            $streamReader = new StreamReader($file);
+        } elseif (\is_string($file)) {
+            $streamReader = StreamReader::createByFile($file);
+            $this->createdReaders[] = $id;
+        } else {
+            $streamReader = $file;
+        }
+
+        $reader = new PdfReader($this->getPdfParserInstance($streamReader));
+        /** @noinspection OffsetOperationsInspection */
+        $this->readers[$id] = $reader;
+
+        return $id;
+    }
+
+    /**
+     * Get a pdf reader instance by its id.
+     *
+     * @param string $id
+     * @return PdfReader
+     */
+    protected function getPdfReader($id)
+    {
+        if (isset($this->readers[$id])) {
+            return $this->readers[$id];
+        }
+
+        throw new \InvalidArgumentException(
+            \sprintf('No pdf reader with the given id (%s) exists.', $id)
+        );
+    }
+
+    /**
+     * Set the source PDF file.
+     *
+     * @param string|resource|StreamReader $file Path to the file or a stream resource or a StreamReader instance.
+     * @return int The page count of the PDF document.
+     * @throws PdfParserException
+     */
+    public function setSourceFile($file)
+    {
+        $this->currentReaderId = $this->getPdfReaderId($file);
+        $this->objectsToCopy[$this->currentReaderId] = [];
+
+        $reader = $this->getPdfReader($this->currentReaderId);
+        $this->setMinPdfVersion($reader->getPdfVersion());
+
+        return $reader->getPageCount();
+    }
+
+    /**
+     * Imports a page.
+     *
+     * @param int $pageNumber The page number.
+     * @param string $box The page boundary to import. Default set to PageBoundaries::CROP_BOX.
+     * @param bool $groupXObject Define the form XObject as a group XObject to support transparency (if used).
+     * @return string A unique string identifying the imported page.
+     * @throws CrossReferenceException
+     * @throws FilterException
+     * @throws PdfParserException
+     * @throws PdfTypeException
+     * @throws PdfReaderException
+     * @see PageBoundaries
+     */
+    public function importPage($pageNumber, $box = PageBoundaries::CROP_BOX, $groupXObject = true)
+    {
+        if (null === $this->currentReaderId) {
+            throw new \BadMethodCallException('No reader initiated. Call setSourceFile() first.');
+        }
+
+        $pageId = $this->currentReaderId;
+
+        $pageNumber = (int)$pageNumber;
+        $pageId .= '|' . $pageNumber . '|' . ($groupXObject ? '1' : '0');
+
+        // for backwards compatibility with FPDI 1
+        $box = \ltrim($box, '/');
+        if (!PageBoundaries::isValidName($box)) {
+            throw new \InvalidArgumentException(
+                \sprintf('Box name is invalid: "%s"', $box)
+            );
+        }
+
+        $pageId .= '|' . $box;
+
+        if (isset($this->importedPages[$pageId])) {
+            return $pageId;
+        }
+
+        $reader = $this->getPdfReader($this->currentReaderId);
+        $page = $reader->getPage($pageNumber);
+
+        $bbox = $page->getBoundary($box);
+        if ($bbox === false) {
+            throw new PdfReaderException(
+                \sprintf("Page doesn't have a boundary box (%s).", $box),
+                PdfReaderException::MISSING_DATA
+            );
+        }
+
+        $dict = new PdfDictionary();
+        $dict->value['Type'] = PdfName::create('XObject');
+        $dict->value['Subtype'] = PdfName::create('Form');
+        $dict->value['FormType'] = PdfNumeric::create(1);
+        $dict->value['BBox'] = $bbox->toPdfArray();
+
+        if ($groupXObject) {
+            $this->setMinPdfVersion('1.4');
+            $dict->value['Group'] = PdfDictionary::create([
+                'Type' => PdfName::create('Group'),
+                'S' => PdfName::create('Transparency')
+            ]);
+        }
+
+        $resources = $page->getAttribute('Resources');
+        if ($resources !== null) {
+            $dict->value['Resources'] = $resources;
+        }
+
+        list($width, $height) = $page->getWidthAndHeight($box);
+
+        $a = 1;
+        $b = 0;
+        $c = 0;
+        $d = 1;
+        $e = -$bbox->getLlx();
+        $f = -$bbox->getLly();
+
+        $rotation = $page->getRotation();
+
+        if ($rotation !== 0) {
+            $rotation *= -1;
+            $angle = $rotation * M_PI/180;
+            $a = \cos($angle);
+            $b = \sin($angle);
+            $c = -$b;
+            $d = $a;
+
+            switch ($rotation) {
+                case -90:
+                    $e = -$bbox->getLly();
+                    $f = $bbox->getUrx();
+                    break;
+                case -180:
+                    $e = $bbox->getUrx();
+                    $f = $bbox->getUry();
+                    break;
+                case -270:
+                    $e = $bbox->getUry();
+                    $f = -$bbox->getLlx();
+                    break;
+            }
+        }
+
+        // we need to rotate/translate
+        if ($a != 1 || $b != 0 || $c != 0 || $d != 1 || $e != 0 || $f != 0) {
+            $dict->value['Matrix'] = PdfArray::create([
+                PdfNumeric::create($a), PdfNumeric::create($b), PdfNumeric::create($c),
+                PdfNumeric::create($d), PdfNumeric::create($e), PdfNumeric::create($f)
+            ]);
+        }
+
+        // try to use the existing content stream
+        $pageDict = $page->getPageDictionary();
+
+        $contentsObject = PdfType::resolve(PdfDictionary::get($pageDict, 'Contents'), $reader->getParser(), true);
+        $contents =  PdfType::resolve($contentsObject, $reader->getParser());
+
+        // just copy the stream reference if it is only a single stream
+        if (($contentsIsStream = ($contents instanceof PdfStream))
+            || ($contents instanceof PdfArray && \count($contents->value) === 1)
+        ) {
+            if ($contentsIsStream) {
+                /**
+                 * @var PdfIndirectObject $contentsObject
+                 */
+                $stream = $contents;
+            } else {
+                $stream = PdfType::resolve($contents->value[0], $reader->getParser());
+            }
+
+            $filter = PdfDictionary::get($stream->value, 'Filter');
+            if (!$filter instanceof PdfNull) {
+                $dict->value['Filter'] = $filter;
+            }
+            $length = PdfType::resolve(PdfDictionary::get($stream->value, 'Length'), $reader->getParser());
+            $dict->value['Length'] = $length;
+            $stream->value = $dict;
+
+        // otherwise extract it from the array and re-compress the whole stream
+        } else {
+            $streamContent = $this->compress
+                ? \gzcompress($page->getContentStream())
+                : $page->getContentStream();
+
+            $dict->value['Length'] = PdfNumeric::create(\strlen($streamContent));
+            if ($this->compress) {
+                $dict->value['Filter'] = PdfName::create('FlateDecode');
+            }
+
+            $stream = PdfStream::create($dict, $streamContent);
+        }
+
+        $this->importedPages[$pageId] = [
+            'objectNumber' => null,
+            'readerId' => $this->currentReaderId,
+            'id' => 'TPL' . $this->getNextTemplateId(),
+            'width' => $width / $this->k,
+            'height' => $height / $this->k,
+            'stream' => $stream
+        ];
+
+        return $pageId;
+    }
+
+    /**
+     * Draws an imported page onto the page.
+     *
+     * Give only one of the size parameters (width, height) to calculate the other one automatically in view to the
+     * aspect ratio.
+     *
+     * @param mixed $pageId The page id
+     * @param float|int|array $x The abscissa of upper-left corner. Alternatively you could use an assoc array
+     *                           with the keys "x", "y", "width", "height", "adjustPageSize".
+     * @param float|int $y The ordinate of upper-left corner.
+     * @param float|int|null $width The width.
+     * @param float|int|null $height The height.
+     * @param bool $adjustPageSize
+     * @return array The size.
+     * @see Fpdi::getTemplateSize()
+     */
+    public function useImportedPage($pageId, $x = 0, $y = 0, $width = null, $height = null, $adjustPageSize = false)
+    {
+        if (\is_array($x)) {
+            /** @noinspection OffsetOperationsInspection */
+            unset($x['pageId']);
+            \extract($x, EXTR_IF_EXISTS);
+            /** @noinspection NotOptimalIfConditionsInspection */
+            if (\is_array($x)) {
+                $x = 0;
+            }
+        }
+
+        if (!isset($this->importedPages[$pageId])) {
+            throw new \InvalidArgumentException('Imported page does not exist!');
+        }
+
+        $importedPage = $this->importedPages[$pageId];
+
+        $originalSize = $this->getTemplateSize($pageId);
+        $newSize = $this->getTemplateSize($pageId, $width, $height);
+        if ($adjustPageSize) {
+            $this->setPageFormat($newSize, $newSize['orientation']);
+        }
+
+        $this->_out(
+            // reset standard values, translate and scale
+            \sprintf(
+                'q 0 J 1 w 0 j 0 G 0 g %.4F 0 0 %.4F %.4F %.4F cm /%s Do Q',
+                ($newSize['width'] / $originalSize['width']),
+                ($newSize['height'] / $originalSize['height']),
+                $x * $this->k,
+                ($this->h - $y - $newSize['height']) * $this->k,
+                $importedPage['id']
+            )
+        );
+
+        return $newSize;
+    }
+
+    /**
+     * Get the size of an imported page.
+     *
+     * Give only one of the size parameters (width, height) to calculate the other one automatically in view to the
+     * aspect ratio.
+     *
+     * @param mixed $tpl The template id
+     * @param float|int|null $width The width.
+     * @param float|int|null $height The height.
+     * @return array|bool An array with following keys: width, height, 0 (=width), 1 (=height), orientation (L or P)
+     */
+    public function getImportedPageSize($tpl, $width = null, $height = null)
+    {
+        if (isset($this->importedPages[$tpl])) {
+            $importedPage = $this->importedPages[$tpl];
+
+            if ($width === null && $height === null) {
+                $width = $importedPage['width'];
+                $height = $importedPage['height'];
+            } elseif ($width === null) {
+                $width = $height * $importedPage['width'] / $importedPage['height'];
+            }
+
+            if ($height  === null) {
+                $height = $width * $importedPage['height'] / $importedPage['width'];
+            }
+
+            if ($height <= 0. || $width <= 0.) {
+                throw new \InvalidArgumentException('Width or height parameter needs to be larger than zero.');
+            }
+
+            return [
+                'width' => $width,
+                'height' => $height,
+                0 => $width,
+                1 => $height,
+                'orientation' => $width > $height ? 'L' : 'P'
+            ];
+        }
+
+        return false;
+    }
+
+    /**
+     * Writes a PdfType object to the resulting buffer.
+     *
+     * @param PdfType $value
+     * @throws PdfTypeException
+     */
+    protected function writePdfType(PdfType $value)
+    {
+        if ($value instanceof PdfNumeric) {
+            if (\is_int($value->value)) {
+                $this->_put($value->value . ' ', false);
+            } else {
+                $this->_put(\rtrim(\rtrim(\sprintf('%.5F', $value->value), '0'), '.') . ' ', false);
+            }
+
+        } elseif ($value instanceof PdfName) {
+            $this->_put('/' . $value->value . ' ', false);
+
+        } elseif ($value instanceof PdfString) {
+            $this->_put('(' . $value->value . ')', false);
+
+        } elseif ($value instanceof PdfHexString) {
+            $this->_put('<' . $value->value . '>');
+
+        } elseif ($value instanceof PdfBoolean) {
+            $this->_put($value->value ? 'true ' : 'false ', false);
+
+        } elseif ($value instanceof PdfArray) {
+            $this->_put('[', false);
+            foreach ($value->value as $entry) {
+                $this->writePdfType($entry);
+            }
+            $this->_put(']');
+
+        } elseif ($value instanceof PdfDictionary) {
+            $this->_put('<<', false);
+            foreach ($value->value as $name => $entry) {
+                $this->_put('/' . $name . ' ', false);
+                $this->writePdfType($entry);
+            }
+            $this->_put('>>');
+
+        } elseif ($value instanceof PdfToken) {
+            $this->_put($value->value);
+
+        } elseif ($value instanceof PdfNull) {
+            $this->_put('null ');
+
+        } elseif ($value instanceof PdfStream) {
+            /**
+             * @var $value PdfStream
+             */
+            $this->writePdfType($value->value);
+            $this->_put('stream');
+            $this->_put($value->getStream());
+            $this->_put('endstream');
+
+        } elseif ($value instanceof PdfIndirectObjectReference) {
+            if (!isset($this->objectMap[$this->currentReaderId])) {
+                $this->objectMap[$this->currentReaderId] = [];
+            }
+
+            if (!isset($this->objectMap[$this->currentReaderId][$value->value])) {
+                $this->objectMap[$this->currentReaderId][$value->value] = ++$this->n;
+                $this->objectsToCopy[$this->currentReaderId][] = $value->value;
+            }
+
+            $this->_put($this->objectMap[$this->currentReaderId][$value->value] . ' 0 R ', false);
+
+        } elseif ($value instanceof PdfIndirectObject) {
+            /**
+             * @var $value PdfIndirectObject
+             */
+            $n = $this->objectMap[$this->currentReaderId][$value->objectNumber];
+            $this->_newobj($n);
+            $this->writePdfType($value->value);
+            $this->_put('endobj');
+        }
+    }
+}
diff --git a/mod/assign/feedback/editpdf/fpdi/LICENSE b/mod/assign/feedback/editpdf/fpdi/LICENSE
deleted file mode 100644 (file)
index e33384d..0000000
+++ /dev/null
@@ -1,21 +0,0 @@
-The MIT License (MIT)\r
-\r
-Copyright (c) 2017 Setasign - Jan Slabon, https://www.setasign.com\r
-\r
-Permission is hereby granted, free of charge, to any person obtaining a copy\r
-of this software and associated documentation files (the "Software"), to deal\r
-in the Software without restriction, including without limitation the rights\r
-to use, copy, modify, merge, publish, distribute, sublicense, and/or sell\r
-copies of the Software, and to permit persons to whom the Software is\r
-furnished to do so, subject to the following conditions:\r
-\r
-The above copyright notice and this permission notice shall be included in\r
-all copies or substantial portions of the Software.\r
-\r
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\r
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\r
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\r
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\r
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\r
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN\r
-THE SOFTWARE.
\ No newline at end of file
diff --git a/mod/assign/feedback/editpdf/fpdi/PdfParser/CrossReference/AbstractReader.php b/mod/assign/feedback/editpdf/fpdi/PdfParser/CrossReference/AbstractReader.php
new file mode 100644 (file)
index 0000000..1d5c579
--- /dev/null
@@ -0,0 +1,96 @@
+<?php
+/**
+ * This file is part of FPDI
+ *
+ * @package   setasign\Fpdi
+ * @copyright Copyright (c) 2019 Setasign - Jan Slabon (https://www.setasign.com)
+ * @license   http://opensource.org/licenses/mit-license The MIT License
+ */
+
+namespace setasign\Fpdi\PdfParser\CrossReference;
+
+use setasign\Fpdi\PdfParser\PdfParser;
+use setasign\Fpdi\PdfParser\Type\PdfDictionary;
+use setasign\Fpdi\PdfParser\Type\PdfToken;
+use setasign\Fpdi\PdfParser\Type\PdfTypeException;
+
+/**
+ * Abstract class for cross-reference reader classes.
+ *
+ * @package setasign\Fpdi\PdfParser\CrossReference
+ */
+abstract class AbstractReader
+{
+    /**
+     * @var PdfParser
+     */
+    protected $parser;
+
+    /**
+     * @var PdfDictionary
+     */
+    protected $trailer;
+
+    /**
+     * AbstractReader constructor.
+     *
+     * @param PdfParser $parser
+     * @throws CrossReferenceException
+     * @throws PdfTypeException
+     */
+    public function __construct(PdfParser $parser)
+    {
+        $this->parser = $parser;
+        $this->readTrailer();
+    }
+
+    /**
+     * Get the trailer dictionary.
+     *
+     * @return PdfDictionary
+     */
+    public function getTrailer()
+    {
+        return $this->trailer;
+    }
+
+    /**
+     * Read the trailer dictionary.
+     *
+     * @throws CrossReferenceException
+     * @throws PdfTypeException
+     */
+    protected function readTrailer()
+    {
+        try {
+            $trailerKeyword = $this->parser->readValue(null, PdfToken::class);
+            if ($trailerKeyword->value !== 'trailer') {
+                throw new CrossReferenceException(
+                    \sprintf(
+                        'Unexpected end of cross reference. "trailer"-keyword expected, got: %s.',
+                        $trailerKeyword->value
+                    ),
+                    CrossReferenceException::UNEXPECTED_END
+                );
+            }
+        } catch (PdfTypeException $e) {
+            throw new CrossReferenceException(
+                'Unexpected end of cross reference. "trailer"-keyword expected, got an invalid object type.',
+                CrossReferenceException::UNEXPECTED_END,
+                $e
+            );
+        }
+
+        try {
+            $trailer = $this->parser->readValue(null, PdfDictionary::class);
+        } catch (PdfTypeException $e) {
+            throw new CrossReferenceException(
+                'Unexpected end of cross reference. Trailer not found.',
+                CrossReferenceException::UNEXPECTED_END,
+                $e
+            );
+        }
+
+        $this->trailer = $trailer;
+    }
+}
diff --git a/mod/assign/feedback/editpdf/fpdi/PdfParser/CrossReference/CrossReference.php b/mod/assign/feedback/editpdf/fpdi/PdfParser/CrossReference/CrossReference.php
new file mode 100644 (file)
index 0000000..95674ce
--- /dev/null
@@ -0,0 +1,320 @@
+<?php
+/**
+ * This file is part of FPDI
+ *
+ * @package   setasign\Fpdi
+ * @copyright Copyright (c) 2019 Setasign - Jan Slabon (https://www.setasign.com)
+ * @license   http://opensource.org/licenses/mit-license The MIT License
+ */
+
+namespace setasign\Fpdi\PdfParser\CrossReference;
+
+use setasign\Fpdi\PdfParser\PdfParser;
+use setasign\Fpdi\PdfParser\Type\PdfDictionary;
+use setasign\Fpdi\PdfParser\Type\PdfIndirectObject;
+use setasign\Fpdi\PdfParser\Type\PdfNumeric;
+use setasign\Fpdi\PdfParser\Type\PdfStream;
+use setasign\Fpdi\PdfParser\Type\PdfToken;
+use setasign\Fpdi\PdfParser\Type\PdfTypeException;
+
+/**
+ * Class CrossReference
+ *
+ * This class processes the standard cross reference of a PDF document.
+ *
+ * @package setasign\Fpdi\PdfParser\CrossReference
+ */
+class CrossReference
+{
+    /**
+     * The byte length in which the "startxref" keyword should be searched.
+     *
+     * @var int
+     */
+    static public $trailerSearchLength = 5500;
+
+    /**
+     * @var int
+     */
+    protected $fileHeaderOffset = 0;
+
+    /**
+     * @var PdfParser
+     */
+    protected $parser;
+
+    /**
+     * @var ReaderInterface[]
+     */
+    protected $readers = [];
+
+    /**
+     * CrossReference constructor.
+     *
+     * @param PdfParser $parser
+     * @throws CrossReferenceException
+     * @throws PdfTypeException
+     */
+    public function __construct(PdfParser $parser, $fileHeaderOffset = 0)
+    {
+        $this->parser = $parser;
+        $this->fileHeaderOffset = $fileHeaderOffset;
+
+        $offset = $this->findStartXref();
+        $reader = null;
+        /** @noinspection TypeUnsafeComparisonInspection */
+        while ($offset != false) { // By doing an unsafe comparsion we ignore faulty references to byte offset 0
+            try {
+                $reader = $this->readXref($offset + $this->fileHeaderOffset);
+            } catch (CrossReferenceException $e) {
+                // sometimes the file header offset is part of the byte offsets, so let's retry by resetting it to zero.
+                if ($e->getCode() === CrossReferenceException::INVALID_DATA && $this->fileHeaderOffset !== 0) {
+                    $this->fileHeaderOffset = 0;
+                    $reader = $this->readXref($offset + $this->fileHeaderOffset);
+                } else {
+                    throw $e;
+                }
+            }
+
+            $trailer = $reader->getTrailer();
+            $this->checkForEncryption($trailer);
+            $this->readers[] = $reader;
+
+            if (isset($trailer->value['Prev'])) {
+                $offset = $trailer->value['Prev']->value;
+            } else {
+                $offset = false;
+            }
+        }
+
+        // fix faulty sub-section header
+        if ($reader instanceof FixedReader) {
+            /**
+             * @var FixedReader $reader
+             */
+            $reader->fixFaultySubSectionShift();
+        }
+
+        if ($reader === null) {
+            throw new CrossReferenceException('No cross-reference found.', CrossReferenceException::NO_XREF_FOUND);
+        }
+    }
+
+    /**
+     * Get the size of the cross reference.
+     *
+     * @return integer
+     */
+    public function getSize()
+    {
+        return $this->getTrailer()->value['Size']->value;
+    }
+
+    /**
+     * Get the trailer dictionary.
+     *
+     * @return PdfDictionary
+     */
+    public function getTrailer()
+    {
+        return $this->readers[0]->getTrailer();
+    }
+
+    /**
+     * Get the cross reference readser instances.
+     *
+     * @return ReaderInterface[]
+     */
+    public function getReaders()
+    {
+        return $this->readers;
+    }
+
+    /**
+     * Get the offset by an object number.
+     *
+     * @param int $objectNumber
+     * @return integer|bool
+     */
+    public function getOffsetFor($objectNumber)
+    {
+        foreach ($this->getReaders() as $reader) {
+            $offset = $reader->getOffsetFor($objectNumber);
+            if ($offset !== false) {
+                return $offset;
+            }
+        }
+
+        return false;
+    }
+
+    /**
+     * Get an indirect object by its object number.
+     *
+     * @param int $objectNumber
+     * @return PdfIndirectObject
+     * @throws CrossReferenceException
+     */
+    public function getIndirectObject($objectNumber)
+    {
+        $offset = $this->getOffsetFor($objectNumber);
+        if ($offset === false) {
+            throw new CrossReferenceException(
+                \sprintf('Object (id:%s) not found.', $objectNumber),
+                CrossReferenceException::OBJECT_NOT_FOUND
+            );
+        }
+
+        $parser = $this->parser;
+
+        $parser->getTokenizer()->clearStack();
+        $parser->getStreamReader()->reset($offset + $this->fileHeaderOffset);
+
+        try {
+            /** @var PdfIndirectObject $object */
+            $object = $parser->readValue(null, PdfIndirectObject::class);
+        } catch (PdfTypeException $e) {
+            throw new CrossReferenceException(
+                \sprintf('Object (id:%s) not found at location (%s).', $objectNumber, $offset),
+                CrossReferenceException::OBJECT_NOT_FOUND,
+                $e
+            );
+        }
+
+        if ($object->objectNumber !== $objectNumber) {
+            throw new CrossReferenceException(
+                \sprintf('Wrong object found, got %s while %s was expected.', $object->objectNumber, $objectNumber),
+                CrossReferenceException::OBJECT_NOT_FOUND
+            );
+        }
+
+        return $object;
+    }
+
+    /**
+     * Read the cross-reference table at a given offset.
+     *
+     * Internally the method will try to evaluate the best reader for this cross-reference.
+     *
+     * @param int $offset
+     * @return ReaderInterface
+     * @throws CrossReferenceException
+     * @throws PdfTypeException
+     */
+    protected function readXref($offset)
+    {
+        $this->parser->getStreamReader()->reset($offset);
+        $this->parser->getTokenizer()->clearStack();
+        $initValue = $this->parser->readValue();
+        
+        return $this->initReaderInstance($initValue);
+    }
+
+    /**
+     * Get a cross-reference reader instance.
+     *
+     * @param PdfToken|PdfIndirectObject $initValue
+     * @return ReaderInterface|bool
+     * @throws CrossReferenceException
+     * @throws PdfTypeException
+     */
+    protected function initReaderInstance($initValue)
+    {
+        $position = $this->parser->getStreamReader()->getPosition()
+            + $this->parser->getStreamReader()->getOffset() + $this->fileHeaderOffset;
+
+        if ($initValue instanceof PdfToken && $initValue->value === 'xref') {
+            try {
+                return new FixedReader($this->parser);
+            } catch (CrossReferenceException $e) {
+                $this->parser->getStreamReader()->reset($position);
+                $this->parser->getTokenizer()->clearStack();
+
+                return new LineReader($this->parser);
+            }
+        }
+
+        if ($initValue instanceof PdfIndirectObject) {
+            // check for encryption
+            $stream = PdfStream::ensure($initValue->value);
+
+            $type = PdfDictionary::get($stream->value, 'Type');
+            if ($type->value !== 'XRef') {
+                throw new CrossReferenceException(
+                    'The xref position points to an incorrect object type.',
+                    CrossReferenceException::INVALID_DATA
+                );
+            }
+
+            $this->checkForEncryption($stream->value);
+
+            throw new CrossReferenceException(
+                'This PDF document probably uses a compression technique which is not supported by the ' .
+                'free parser shipped with FPDI. (See https://www.setasign.com/fpdi-pdf-parser for more details)',
+                CrossReferenceException::COMPRESSED_XREF
+            );
+        }
+
+        throw new CrossReferenceException(
+            'The xref position points to an incorrect object type.',
+            CrossReferenceException::INVALID_DATA
+        );
+    }
+
+    /**
+     * Check for encryption.
+     *
+     * @param PdfDictionary $dictionary
+     * @throws CrossReferenceException
+     */
+    protected function checkForEncryption(PdfDictionary $dictionary)
+    {
+        if (isset($dictionary->value['Encrypt'])) {
+            throw new CrossReferenceException(
+                'This PDF document is encrypted and cannot be processed with FPDI.',
+                CrossReferenceException::ENCRYPTED
+            );
+        }
+    }
+
+    /**
+     * Find the start position for the first cross-reference.
+     *
+     * @return int The byte-offset position of the first cross-reference.
+     * @throws CrossReferenceException
+     */
+    protected function findStartXref()
+    {
+        $reader = $this->parser->getStreamReader();
+        $reader->reset(-self::$trailerSearchLength, self::$trailerSearchLength);
+
+        $buffer = $reader->getBuffer(false);
+        $pos = \strrpos($buffer, 'startxref');
+        $addOffset = 9;
+        if ($pos === false) {
+            // Some corrupted documents uses startref, instead of startxref
+            $pos = \strrpos($buffer, 'startref');
+            if ($pos === false) {
+                throw new CrossReferenceException(
+                    'Unable to find pointer to xref table',
+                    CrossReferenceException::NO_STARTXREF_FOUND
+                );
+            }
+            $addOffset = 8;
+        }
+
+        $reader->setOffset($pos + $addOffset);
+
+        try {
+            $value = $this->parser->readValue(null, PdfNumeric::class);
+        } catch (PdfTypeException $e) {
+            throw new CrossReferenceException(
+                'Invalid data after startxref keyword.',
+                CrossReferenceException::INVALID_DATA,
+                $e
+            );
+        }
+
+        return $value->value;
+    }
+}
diff --git a/mod/assign/feedback/editpdf/fpdi/PdfParser/CrossReference/CrossReferenceException.php b/mod/assign/feedback/editpdf/fpdi/PdfParser/CrossReference/CrossReferenceException.php
new file mode 100644 (file)
index 0000000..3fa9b33
--- /dev/null
@@ -0,0 +1,80 @@
+<?php
+/**
+ * This file is part of FPDI
+ *
+ * @package   setasign\Fpdi
+ * @copyright Copyright (c) 2019 Setasign - Jan Slabon (https://www.setasign.com)
+ * @license   http://opensource.org/licenses/mit-license The MIT License
+ */
+
+namespace setasign\Fpdi\PdfParser\CrossReference;
+
+use setasign\Fpdi\PdfParser\PdfParserException;
+
+/**
+ * Exception used by the CrossReference and Reader classes.
+ *
+ * @package setasign\Fpdi\PdfParser\CrossReference
+ */
+class CrossReferenceException extends PdfParserException
+{
+    /**
+     * @var int
+     */
+    const INVALID_DATA = 0x0101;
+
+    /**
+     * @var int
+     */
+    const XREF_MISSING = 0x0102;
+
+    /**
+     * @var int
+     */
+    const ENTRIES_TOO_LARGE = 0x0103;
+
+    /**
+     * @var int
+     */
+    const ENTRIES_TOO_SHORT = 0x0104;
+
+    /**
+     * @var int
+     */
+    const NO_ENTRIES = 0x0105;
+
+    /**
+     * @var int
+     */
+    const NO_TRAILER_FOUND = 0x0106;
+
+    /**
+     * @var int
+     */
+    const NO_STARTXREF_FOUND = 0x0107;
+
+    /**
+     * @var int
+     */
+    const NO_XREF_FOUND = 0x0108;
+
+    /**
+     * @var int
+     */
+    const UNEXPECTED_END = 0x0109;
+
+    /**
+     * @var int
+     */
+    const OBJECT_NOT_FOUND = 0x010A;
+
+    /**
+     * @var int
+     */
+    const COMPRESSED_XREF = 0x010B;
+
+    /**
+     * @var int
+     */
+    const ENCRYPTED = 0x010C;
+}
diff --git a/mod/assign/feedback/editpdf/fpdi/PdfParser/CrossReference/FixedReader.php b/mod/assign/feedback/editpdf/fpdi/PdfParser/CrossReference/FixedReader.php
new file mode 100644 (file)
index 0000000..f96b979
--- /dev/null
@@ -0,0 +1,196 @@
+<?php
+/**
+ * This file is part of FPDI
+ *
+ * @package   setasign\Fpdi
+ * @copyright Copyright (c) 2019 Setasign - Jan Slabon (https://www.setasign.com)
+ * @license   http://opensource.org/licenses/mit-license The MIT License
+ */
+
+namespace setasign\Fpdi\PdfParser\CrossReference;
+
+use setasign\Fpdi\PdfParser\PdfParser;
+use setasign\Fpdi\PdfParser\StreamReader;
+
+/**
+ * Class FixedReader
+ *
+ * This reader allows a very less overhead parsing of single entries of the cross-reference, because the main entries
+ * are only read when needed and not in a single run.
+ *
+ * @package setasign\Fpdi\PdfParser\CrossReference
+ */
+class FixedReader extends AbstractReader implements ReaderInterface
+{
+    /**
+     * @var StreamReader
+     */
+    protected $reader;
+
+    /**
+     * Data of subsections.
+     *
+     * @var array
+     */
+    protected $subSections;
+
+    /**
+     * FixedReader constructor.
+     *
+     * @param PdfParser $parser
+     * @throws CrossReferenceException
+     */
+    public function __construct(PdfParser $parser)
+    {
+        $this->reader = $parser->getStreamReader();
+        $this->read();
+        parent::__construct($parser);
+    }
+
+    /**
+     * Get all subsection data.
+     *
+     * @return array
+     */
+    public function getSubSections()
+    {
+        return $this->subSections;
+    }
+
+    /**
+     * @inheritdoc
+     */
+    public function getOffsetFor($objectNumber)
+    {
+        foreach ($this->subSections as $offset => list($startObject, $objectCount)) {
+            if ($objectNumber >= $startObject && $objectNumber < ($startObject + $objectCount)) {
+                $position = $offset + 20 * ($objectNumber - $startObject);
+                $this->reader->ensure($position, 20);
+                $line = $this->reader->readBytes(20);
+                if ($line[17] === 'f') {
+                    return false;
+                }
+
+                return (int) \substr($line, 0, 10);
+            }
+        }
+
+        return false;
+    }
+
+    /**
+     * Read the cross-reference.
+     *
+     * This reader will only read the subsections in this method. The offsets were resolved individually by this
+     * information.
+     *
+     * @throws CrossReferenceException
+     */
+    protected function read()
+    {
+        $subSections = [];
+
+        $startObject = $entryCount = $lastLineStart = null;
+        $validityChecked = false;
+        while (($line = $this->reader->readLine(20)) !== false) {
+            if (\strpos($line, 'trailer') !== false) {
+                $this->reader->reset($lastLineStart);
+                break;
+            }
+
+            // jump over if line content doesn't match the expected string
+            if (\sscanf($line, '%d %d', $startObject, $entryCount) !== 2) {
+                continue;
+            }
+
+            $oldPosition = $this->reader->getPosition();
+            $position = $oldPosition + $this->reader->getOffset();
+
+            if (!$validityChecked && $entryCount > 0) {
+                $nextLine = $this->reader->readBytes(21);
+                /* Check the next line for maximum of 20 bytes and not longer
+                 * By catching 21 bytes and trimming the length should be still 21.
+                 */
+                if (\strlen(\trim($nextLine)) !== 21) {
+                    throw new CrossReferenceException(
+                        'Cross-reference entries are larger than 20 bytes.',
+                        CrossReferenceException::ENTRIES_TOO_LARGE
+                    );
+                }
+
+                /* Check for less than 20 bytes: cut the line to 20 bytes and trim; have to result in exactly 18 bytes.
+                 * If it would have less bytes the substring would get the first bytes of the next line which would
+                 * evaluate to a 20 bytes long string after trimming.
+                 */
+                if (\strlen(\trim(\substr($nextLine, 0, 20))) !== 18) {
+                    throw new CrossReferenceException(
+                        'Cross-reference entries are less than 20 bytes.',
+                        CrossReferenceException::ENTRIES_TOO_SHORT
+                    );
+                }
+
+                $validityChecked = true;
+            }
+
+            $subSections[$position] = [$startObject, $entryCount];
+
+            $lastLineStart = $position + $entryCount * 20;
+            $this->reader->reset($lastLineStart);
+        }
+
+        // reset after the last correct parsed line
+        $this->reader->reset($lastLineStart);
+
+        if (\count($subSections) === 0) {
+            throw new CrossReferenceException(
+                'No entries found in cross-reference.',
+                CrossReferenceException::NO_ENTRIES
+            );
+        }
+
+        $this->subSections = $subSections;
+    }
+
+    /**
+     * Fixes an invalid object number shift.
+     *
+     * This method can be used to repair documents with an invalid subsection header:
+     *
+     * <code>
+     * xref
+     * 1 7
+     * 0000000000 65535 f
+     * 0000000009 00000 n
+     * 0000412075 00000 n
+     * 0000412172 00000 n
+     * 0000412359 00000 n
+     * 0000412417 00000 n
+     * 0000412468 00000 n
+     * </code>
+     *
+     * It shall only be called on the first table.
+     *
+     * @return bool
+     */
+    public function fixFaultySubSectionShift()
+    {
+        $subSections = $this->getSubSections();
+        if (\count($subSections) > 1) {
+            return false;
+        }
+
+        $subSection = \current($subSections);
+        if ($subSection[0] != 1) {
+            return false;
+        }
+
+        if ($this->getOffsetFor(1) === false) {
+            foreach ($subSections as $offset => list($startObject, $objectCount)) {
+                $this->subSections[$offset] = [$startObject - 1, $objectCount];
+            }
+            return true;
+        }
+
+        return false;
+    }
+}
diff --git a/mod/assign/feedback/editpdf/fpdi/PdfParser/CrossReference/LineReader.php b/mod/assign/feedback/editpdf/fpdi/PdfParser/CrossReference/LineReader.php
new file mode 100644 (file)
index 0000000..31a96be
--- /dev/null
@@ -0,0 +1,173 @@
+<?php
+/**
+ * This file is part of FPDI
+ *
+ * @package   setasign\Fpdi
+ * @copyright Copyright (c) 2019 Setasign - Jan Slabon (https://www.setasign.com)
+ * @license   http://opensource.org/licenses/mit-license The MIT License
+ */
+
+namespace setasign\Fpdi\PdfParser\CrossReference;
+
+use setasign\Fpdi\PdfParser\PdfParser;
+use setasign\Fpdi\PdfParser\StreamReader;
+
+/**
+ * Class LineReader
+ *
+ * This reader class read all cross-reference entries in a single run.
+ * It supports reading cross-references with e.g. invalid data (e.g. entries with a length < or > 20 bytes).
+ *
+ * @package setasign\Fpdi\PdfParser\CrossReference
+ */
+class LineReader extends AbstractReader implements ReaderInterface
+{
+    /**
+     * The object offsets.
+     *
+     * @var array
+     */
+    protected $offsets;
+
+    /**
+     * LineReader constructor.
+     *
+     * @param PdfParser $parser
+     * @throws CrossReferenceException
+     */
+    public function __construct(PdfParser $parser)
+    {
+        $this->read($this->extract($parser->getStreamReader()));
+        parent::__construct($parser);
+    }
+
+    /**
+     * @inheritdoc
+     */
+    public function getOffsetFor($objectNumber)
+    {
+        if (isset($this->offsets[$objectNumber])) {
+            return $this->offsets[$objectNumber][0];
+        }
+
+        return false;
+    }
+
+    /**
+     * Get all found offsets.
+     *
+     * @return array
+     */
+    public function getOffsets()
+    {
+        return $this->offsets;
+    }
+
+    /**
+     * Extracts the cross reference data from the stream reader.